├── .ebextensions ├── 01.config └── options.config ├── .ebignore ├── .gitignore ├── README.md ├── app ├── DAL │ ├── constants │ │ ├── account_status.py │ │ └── events.py │ ├── models │ │ ├── account_history_module.py │ │ ├── account_module.py │ │ ├── role_module.py │ │ └── user_module.py │ └── services │ │ ├── account_dal.py │ │ ├── auth_forms.py │ │ └── vendor.py ├── __init__.py ├── api │ ├── account_api.py │ ├── auth_api.py │ ├── billing_api.py │ └── dashboard_api.py ├── blueprints │ ├── account_blueprint.py │ ├── auth_blueprint.py │ ├── billing_blueprint.py │ └── dashboard_blueprint.py ├── js │ ├── appAuth.js │ ├── appDashboard.js │ ├── common │ │ ├── commonService.js │ │ └── httpService.js │ ├── leftMenu.js │ ├── styles │ │ ├── stylesAuth.js │ │ └── stylesDashboard.js │ ├── userRoutes.js │ └── views │ │ ├── Pages.vue │ │ ├── Sample_long_page.vue │ │ ├── UI_Element_library_Simple_elements.vue │ │ ├── billing │ │ ├── billing.vue │ │ ├── billingHistory.vue │ │ ├── paymentMethod.vue │ │ ├── plan.vue │ │ └── summary.vue │ │ └── dashboard │ │ ├── breadcrumbs.vue │ │ ├── pageNotFound.vue │ │ └── userProfile.vue ├── scss │ ├── auth.scss │ ├── billing │ │ ├── billing.scss │ │ ├── billingMenu.scss │ │ └── paymentMethod.scss │ └── dashboard.scss ├── templates │ ├── auth │ │ ├── baseAuth.html │ │ ├── confirmed.html │ │ ├── confirmemail.html │ │ ├── login.html │ │ └── register.html │ ├── baseDashboard.html │ ├── common │ │ └── csrf_token.html │ ├── dashboard.html │ ├── leftMenu.html │ └── non_auth │ │ ├── baseNonAuth.html │ │ ├── confirmation.html │ │ ├── confirmation.txt │ │ └── errorpage.html └── utils │ ├── __init__.py │ ├── custom_login_required_api_decorator.py │ ├── dbscaffold.py │ ├── email.py │ ├── error_handler.py │ ├── filters.py │ ├── global_functions.py │ └── guid_module.py ├── application.py ├── config.py ├── init.bat ├── package-lock.json ├── package.json ├── requirements.txt ├── scaffold ├── __init__.py ├── generators │ ├── __init__.py │ ├── common.py │ ├── interface.py │ └── left_menu_generator.py └── left_menu.yaml ├── static └── media │ ├── cc.png │ ├── favicon.ico │ ├── logo.png │ ├── paypal.png │ └── powered_by_stripe.png ├── unittests ├── __init__.py └── scaffold_tests.py ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.ebextensions/01.config: -------------------------------------------------------------------------------- 1 | files: 2 | "/etc/httpd/conf.d/ssl_rewrite.conf": 3 | mode: "000644" 4 | owner: root 5 | group: root 6 | content: | 7 | RewriteEngine On 8 | 9 | RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L] 10 | -------------------------------------------------------------------------------- /.ebextensions/options.config: -------------------------------------------------------------------------------- 1 | option_settings: 2 | aws:elasticbeanstalk:application:environment: 3 | env: dev 4 | db_url: postgres://db 5 | secret_key: key 6 | secret_salt: key 7 | mail_server: mail.privateemail.com 8 | mail_port: 465 9 | mail_username: key 10 | mail_password: key 11 | admin_email: key 12 | TEST_STRIPE_PUBLISHABLE_KEY: key 13 | TEST_STRIPE_SECRET_KEY: key 14 | stripe_endpoint_secret: key -------------------------------------------------------------------------------- /.ebignore: -------------------------------------------------------------------------------- 1 | venv 2 | node_modules 3 | .ebextensions/01.config -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Node modules 7 | node_modules 8 | 9 | # Dist 10 | static/fonts 11 | static/js 12 | 13 | 14 | # Python 15 | __pycache__ 16 | venv 17 | *.pyc 18 | 19 | # Database 20 | # Comment it if you want to keep your migrations files commited 21 | app/migrations 22 | 23 | # macOs 24 | .DS_Store 25 | 26 | # Settings 27 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaaS Base Application 2 | 3 | __Warning. This repo is rebranded from SaaS-Idea. Please update your urls.__ 4 | 5 | This free SaaS base application allows you to create a working SaaS with minimal efforts. What it already has: 6 | 7 | #### User authentication #### 8 | * Email authentication (with email confirmation) 9 | * User registration, login, logout 10 | * Simple user profile page 11 | 12 | #### Payment support #### 13 | * Fully stripe integration (plans list is automatically generated from your Stripe account) 14 | * User plans support 15 | * Payments method support (only credit cards for now, by Stripe) 16 | * Users can select a plan, change it, cancel, pause, resume 17 | * User can see all the history of payment-related actions 18 | * As soon as user logs in, the trial is started automatically (that plan that is marked in Stripe default one) 19 | 20 | #### Dev's features #### 21 | * All features are now divided to units and components. Frontend and backend are put side-by-side for easier reference and development. 22 | * Autocreation of tables for users and roles (2 roles are added automatically: User and Admin) 23 | * Autoupdating existing database 24 | * Simple responsive web interface with header, left collapsing menu, central part, and fixed status bar 25 | * Handling 404 and 500 errors 26 | * Integration with Google App Engine (reading entities if env variables are not accessible) 27 | 28 | #### Small but pretty user friendly features #### 29 | * Breadcrumbs component 30 | * Loaders to show user when data is fetching but still not finished 31 | * Loaders may be easily added to buttons 32 | 33 | 34 | ### Features: ### 35 | * Well organized project structure (blueprints/components based) 36 | * Used bleeding edge web technologies 37 | * Allows to add your own features, pages and components quickly 38 | 39 | ### Technologies/libraries in use: ### 40 | #### Database: #### 41 | * PostgreSQL 42 | #### Backend: #### 43 | * Flask / Python 3 / SQLAlchemy 44 | 45 | #### Frontend: #### 46 | * ES6 JavaScript 47 | * Vue 48 | * Axios 49 | 50 | #### Design / templates: #### 51 | * Bootstrap 4 52 | * Fontawesome 5 53 | * SASS / SCSS 54 | 55 | #### Project organize: #### 56 | * Webpack 4 57 | 58 | ## What does the app look like? 59 | Before you even clone anything it would be nice to show you what eventually you would own. There are 4 screenshots: 60 | * Login 61 | ![Login page](https://saasidea.io/static/images/login.png) 62 | * Register 63 | ![Register page](https://www.saasidea.io/static/images/register.png) 64 | * Confirmed 65 | ![Confirmed page](https://www.saasidea.io/static/images/confirmed.png) 66 | * Dashboard 67 | ![Dashboard page](https://www.saasidea.io/static/images/dashboard.png) 68 | 69 | ### Billing/payments ### 70 | * Billing summary 71 | ![Billing summary](https://www.saasidea.io/static/images/BillingSummary.png) 72 | * Stripe integration 73 | ![Stripe integration](https://www.saasidea.io/static/images/PlansStripeIntegration.png) 74 | * Payment method selection 75 | ![Payment method selection](https://www.saasidea.io/static/images/PaymentMethodSelectionForm.png) 76 | * After user selected plan and pai he/she can pause or cancel it 77 | ![After user selected plan and pai he/she can pause or cancel it](https://www.saasidea.io/static/images/UserSelectedPlanAndPaid.png) 78 | * Billing history 79 | ![Billing history](https://www.saasidea.io/static/images/BillingHistory.png) 80 | 81 | ## Getting Started 82 | 83 | Follow instruction to install, set up and run this boilerplate to start your SaaS quicker. 84 | 85 | ### Prerequisites 86 | 87 | Before we start make sure you have installed Python 3 and Node.js. Please follow the official instructions. Also, you need to have a PostgreSQL database handy. If you don't want to install it you can use ElephantSQL service, they have a free plan: [https://www.elephantsql.com/plans.html](https://www.elephantsql.com/plans.html). 88 | 89 | 90 | ### Installing 91 | 92 | 1. Download the full zip or pull code from the repository, [here](https://help.github.com/articles/which-remote-url-should-i-use/) you can find full instruction: 93 | ``` 94 | git clone https://github.com/CaravelKit/saas-base 95 | cd saas-base 96 | ``` 97 | 2. Create a virtual environment (not necessarily but highly recommended): 98 | ``` 99 | python -m venv venv 100 | ``` 101 | (First 'venv' is a command, the second one is a new folder for a virtual environment. Or you can call it whatever.) 102 | 103 | 104 | 3. Add necessarily environment variables: 105 | * Find venv/Scripts/activate.bat file, open in a text editor (__Important! Don't use Notepad++ as for some reason it spoils the file.__) 106 | * Add the following variables before _:END_: 107 | * set FLASK_APP=application 108 | * set env=dev 109 | * set "db_url=postgres://user:password@dbhost:port/database" 110 | * set "secret_key=your_local_secret_key" 111 | * set "secret_salt=your_local_salt" 112 | * set mail_server=your_email_server 113 | * set mail_port=usually_465 114 | * set "mail_username=your_email" 115 | * set "mail_password=your_email_password" 116 | * set "admin_email=admin_email" 117 | * set "TEST_STRIPE_PUBLISHABLE_KEY=your test publishable key" 118 | * set "TEST_STRIPE_SECRET_KEY=your test secret key" 119 | * set "GOOGLE_APPLICATION_CREDENTIALS=path to your google credential json file" 120 | * set "stripe_endpoint_secret=" 121 | * The same folder find deactivate.bat and add the following strings before _:END_: 122 | * set FLASK_APP= 123 | * set env= 124 | * set db_url= 125 | * set secret_key= 126 | * set secret_salt= 127 | * set mail_server= 128 | * set mail_port= 129 | * set mail_username= 130 | * set mail_password= 131 | * set admin_email= 132 | * set TEST_STRIPE_PUBLISHABLE_KEY=" 133 | * set TEST_STRIPE_SECRET_KEY= 134 | * set GOOGLE_APPLICATION_CREDENTIALS= 135 | * set stripe_endpoint_secret= 136 | 137 | Note: if you use privateemail.com for your email you can set up the following settings: 138 | ``` 139 | set "mail_server=mail.privateemail.com" 140 | set "mail_port=465" 141 | ``` 142 | ### Setting up (quick, automate) 143 | Just run the command: 144 | ``` 145 | init 146 | ``` 147 | Or, from a terminal: 148 | ``` 149 | ./init.bat 150 | ``` 151 | 152 | > Warning! This command clears up your database before creating new entities. If you want just to update your current database, change the following code: 153 | 154 | ``` 155 | call flask dbinit -c 156 | ``` 157 | to 158 | ``` 159 | call flask dbinit -u 160 | ``` 161 | 162 | As soon as you see the following info you can open your browser: 163 | ``` 164 | * Serving Flask app "main" 165 | * Environment: production 166 | WARNING: Do not use the development server in a production environment. 167 | Use a production WSGI server instead. 168 | * Debug mode: off 169 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 170 | ``` 171 | ### Setting up (slow, manual) 172 | 173 | 1. Activate the environment: 174 | ``` 175 | venv/Scripts/activate.bat 176 | ``` 177 | 178 | 2. Move to the venv folder and install Python dependencies: 179 | ``` 180 | pip install -r requirements.txt 181 | ``` 182 | If you see some error you definitely have to update your pip: 183 | ``` 184 | python -m pip install --upgrade pip 185 | ``` 186 | 187 | 3. Move back to the folder where your project is. Install webpack/JavaScript dependencies: 188 | ``` 189 | npm install 190 | ``` 191 | 192 | 4. Build the javascript code and styles: 193 | ``` 194 | npm run dev 195 | ``` 196 | Note, there is another config, for production that you can run with "npm run prod" - in this version you will get well zipped (but not readable) code. 197 | 198 | 5. Initialize the database: 199 | ``` 200 | flask dbinit -c 201 | ``` 202 | 203 | 6. Run the app: 204 | ``` 205 | flask run 206 | ``` 207 | 208 | 7. Open a browser and go http://127.0.0.1:5000/. It will show the 404 error page because there is no any route defined for the root. If you see this page it means everything works fine! Feel free to explore, it's your code now! 209 | 210 | ## How to add/edit breadcrumbs 211 | In file \app\components\dashboard\js\appDashboard.js 212 | change/add the following code: 213 | ``` 214 | var routes = [ 215 | ... 216 | { 217 | path: '/user/profile', 218 | component: UserProfile, 219 | name: 'userProfile', 220 | meta: { 221 | breadcrumb: [ 222 | { name: 'User' }, <= breadcrumb link or text 223 | { name: 'Profile' } // add 'link' field with name or the route 224 | ] 225 | } 226 | }, 227 | ... 228 | ``` 229 | Name your routes to have the access from breadcrumbs to them. 230 | 231 | ## Trial default period (in days) 232 | By default, for a production version it's 14 days, for dev it's just a one day (for easier validation). If you want to change this setting, 233 | please change the corresponding line in config.py: 234 | ``` 235 | TRIAL_PERIOD_IN_DAYS = 1 236 | ``` 237 | 238 | ## How to debug the code 239 | We prefer MS VS Code. It's free and have tons of plugins for any language and framework. We use plugins for Python, Flask, Vue. To debug Python code you need to do some setups: 240 | 241 | 1. Open settings: File -> Preferences --> Settings 242 | 2. In the Workspace settings section add the following data: 243 | ``` 244 | { 245 | "python.pythonPath": "path_to_you_venv/Scripts/python.exe", 246 | "python.venvPath": "path_to_you_venv/Scripts/activate", 247 | "python.linting.pylintEnabled": false, 248 | } 249 | ``` 250 | 3. Follow [this instructions](https://code.visualstudio.com/docs/python/tutorial-flask#_run-the-app-in-the-debugger) to set up launch.json. In our case you should have something like that: 251 | ``` 252 | { 253 | "name": "Python Experimental: Flask", 254 | "type": "pythonExperimental", 255 | "request": "launch", 256 | "module": "flask", 257 | "env": { 258 | "FLASK_APP": "application.py" 259 | }, 260 | "args": [ 261 | "run", 262 | "--no-debugger", 263 | "--no-reload" 264 | //"dbinit", 265 | //"-u" 266 | ], 267 | "jinja": true 268 | } 269 | ``` 270 | 4. To start debugging, open the Terminal, activate the environment from there, the save as we did from the command line, then select Debug-->Start debugging. 271 | 272 | ## How to update the database 273 | 274 | Every time when you change something in your models, run the following command to update the database: 275 | ``` 276 | flask dbinit -u 277 | ``` 278 | 279 | ## Future features 280 | We want to build the great product and we believe it's possible only when we collaborate with our users. So, we created a survey to figure out what is most important for you. Please [fill it up](https://goo.gl/forms/rCjQPeqgdPkTnfmB2) and we will develop next feature on your choice! 281 | 282 | ## Important note about this free version 283 | 284 | This version of our SaaS boilerplate is free and it will NOT have all the features. 285 | 286 | ## Authors 287 | 288 | [Caravel Kit](https://www.caravelkit.com) 289 | 290 | [SaaS Idea](https://www.saasidea.io) 291 | 292 | ## License 293 | 294 | Copyright (c) 2019 Caravel Kit [www.caravelkit.com](www.caravelkit.com) under the [MIT license](https://opensource.org/licenses/MIT). 295 | If you are interested in the full-functional version please check our website [www.caravelkit.com](https://www.caravelkit.com) for pricing and conditions. 296 | 297 | ## Feedback 298 | 299 | * If you find a bug please open an issue or drop us a line at [info@caravelkit.com](info@caravelkit.com). -------------------------------------------------------------------------------- /app/DAL/constants/account_status.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class ValidStatus(Enum): 4 | invalid = 'invalid' # User didn't select a trial, didn't paid, or cancelled subscription 5 | valid = 'valid' 6 | 7 | class AccountStatus(Enum): 8 | undefined = '' 9 | trial = 'trial' # User started trial 10 | paid = 'paid' # User started subscription and paid 11 | paused = 'paused' # User stopped next payment until he decides to activate it again 12 | cancelled = 'cancelled' 13 | rejected = 'rejected' # Account rejected due to payment problems 14 | 15 | class PaymentMethodStatus(Enum): 16 | empty = 'empty' 17 | entered = 'entered' 18 | 19 | class PlanStatus(Enum): 20 | empty = 'empty' 21 | entered = 'entered' 22 | -------------------------------------------------------------------------------- /app/DAL/constants/events.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | class EventType(Enum): 4 | # Payment 5 | trial_started = 'trial_start' 6 | trial_ended = 'trial_end' 7 | plan_selected = 'plan_sel' 8 | plan_changed = 'plan_chan' 9 | payment_method_selected = 'pay_method_sel' 10 | payment_method_changed = 'pay_method_chan' 11 | subscription_paid = 'subscription_paid' 12 | subscription_failed = 'subscription_failed' 13 | subscription_cancelled = 'subscription_cancelled' 14 | subscription_paused = 'subscription_paused' 15 | subscription_resumed = 'subscription_resumed' 16 | 17 | def get_text_event(event: EventType): 18 | dic = { 19 | EventType.trial_started: 'Trial pediod started', 20 | EventType.trial_ended: 'Trial period ended', 21 | EventType.plan_selected : 'Plan selected', 22 | EventType.plan_changed : 'Plan changed', 23 | EventType.payment_method_selected : 'Payment method selected', 24 | EventType.payment_method_changed : 'Payment method changed', 25 | EventType.subscription_paid : 'Subscription paid', 26 | EventType.subscription_failed : 'Subscription payment failed', 27 | EventType.subscription_cancelled : 'Subscription cancelled', 28 | EventType.subscription_paused : 'Subscription paused', 29 | EventType.subscription_resumed : 'Subscription resumed' 30 | } 31 | return dic[event] -------------------------------------------------------------------------------- /app/DAL/models/account_history_module.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from flask import current_app, request, url_for 4 | from flask_login import UserMixin 5 | from flask_bcrypt import generate_password_hash, check_password_hash 6 | from flask_sqlalchemy import SQLAlchemy 7 | from sqlalchemy import inspect 8 | from sqlalchemy.dialects.postgresql import UUID 9 | from sqlalchemy_utils import UUIDType 10 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 11 | 12 | from app import login_manager 13 | from app import db 14 | from app.DAL.constants.events import EventType, get_text_event 15 | 16 | 17 | class AccountHistory(db.Model): 18 | __tablename__ = 'account_history' 19 | id = db.Column(UUID(as_uuid=True), primary_key=True, default=lambda: uuid.uuid4().hex) 20 | account_id = db.Column(UUID(as_uuid=True), db.ForeignKey('account.id')) 21 | date = db.Column(db.DateTime(), nullable=True) 22 | event = db.Column(db.String(32), nullable=True) # Some text representation of event 23 | comment = db.Column(db.String(128)) # Text comment 24 | 25 | def toDict(self): 26 | def read_field(field_value, field_name): 27 | if (field_name == 'event'): 28 | return get_text_event(EventType(field_value)) 29 | res = str(field_value) 30 | return res 31 | return {c.name: read_field(getattr(self, c.name), c.name) for c in self.__table__.columns}#{ c.key: getattr(self, c.key) for c in inspect(self).mapper.column_attrs } 32 | -------------------------------------------------------------------------------- /app/DAL/models/account_module.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from flask import current_app, request, url_for 4 | from flask_login import UserMixin 5 | from flask_bcrypt import generate_password_hash, check_password_hash 6 | from flask_sqlalchemy import SQLAlchemy 7 | from sqlalchemy.dialects.postgresql import UUID 8 | from sqlalchemy_utils import UUIDType 9 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 10 | from datetime import datetime as DateTime, timedelta as TimeDelta 11 | 12 | from app import login_manager 13 | from app import db 14 | from app import get_vendor 15 | 16 | from app.DAL.constants.account_status import AccountStatus, ValidStatus 17 | from app.DAL.constants.events import EventType 18 | from .account_history_module import AccountHistory 19 | 20 | class Account(db.Model): 21 | __tablename__ = 'account' 22 | id = db.Column(UUID(as_uuid=True), primary_key=True, default=lambda: uuid.uuid4().hex) 23 | user = db.relationship('User', back_populates='account') 24 | account_history = db.relationship("AccountHistory", backref='account', lazy='dynamic') 25 | 26 | # payment information 27 | payment_method = db.Column(db.String()) # May be: 'card', 'paypal' or anything else 28 | payment_method_info = db.Column(db.String()) # Vendor - specific information 29 | payment_method_text = db.Column(db.String(150)) # Any text information for user that will be shown as current payment info 30 | account_status_text = db.Column(db.String(150)) # Ane text information describing the current account status (trial, paid, paused etc.) 31 | vendor_name = db.Column(db.String(64)) 32 | subscription_id = db.Column(db.String(128)) 33 | plan_id = db.Column(db.String(64)) 34 | plan_name = db.Column(db.String(128)) 35 | 36 | # Below is current actual information 37 | # If account is valid that is has access to some plan 38 | valid_status = db.Column(db.String(32)) # It's string because alembic cannot update Enums 39 | 40 | # Current status: trial, paid, or paused 41 | account_status = db.Column(db.String(32)) 42 | 43 | # Additional fields 44 | trial_expiration = db.Column(db.DateTime(), nullable=True) 45 | payment_expiration = db.Column(db.DateTime(), nullable=True) 46 | 47 | 48 | 49 | def __init__(self): 50 | self.valid_status = ValidStatus.invalid.value # It's invalid by default 51 | self.start_trial() 52 | #self.account_status = AccountStatus.trial.value # when user signs up, he gets the trial automatically 53 | #trial_days = current_app.config['TRIAL_PERIOD_IN_DAYS'] 54 | #self.trial_expiration = DateTime.today() + TimeDelta(days = trial_days) 55 | 56 | # Checks if account is active or not, change fields and the current status 57 | def check_account_status(self):#trial_status, purchase_status, payment_method_status, plan_status): 58 | current_valid_status = ValidStatus.valid 59 | current_account_status = AccountStatus(self.account_status) 60 | if self.account_status == AccountStatus.trial.value: 61 | # Check if the trial's not expired yet 62 | if DateTime.today() > self.trial_expiration: 63 | current_valid_status = ValidStatus.invalid 64 | current_account_status = AccountStatus.undefined 65 | self.account_status_text = 'Your trial is expired. Please buy a subscription to continue using the service.' 66 | self.trial_expiration = None 67 | self.plan_id = '' 68 | else: 69 | # Trial is inactive or not started yet. Check payment status. 70 | if (self.account_status == AccountStatus.cancelled.value or 71 | self.account_status == AccountStatus.paused.value or 72 | self.account_status == AccountStatus.undefined.value): 73 | current_valid_status = ValidStatus.invalid 74 | else: 75 | # Account status is paid, check if it's still valid 76 | if self.payment_expiration != None and DateTime.now() > self.payment_expiration: 77 | current_valid_status = ValidStatus.invalid 78 | 79 | if current_valid_status.value != self.valid_status or current_account_status.value != self.account_status: 80 | self.valid_status = current_valid_status.value 81 | self.account_status = current_account_status.value 82 | db.session.add(self) 83 | db.session.commit() 84 | 85 | def create_history_event(self, event: EventType, comment: str): 86 | new_event = AccountHistory() 87 | new_event.account_id = self.id 88 | new_event.date = datetime.now() 89 | new_event.event = event.value 90 | new_event.comment = comment 91 | db.session.add(new_event) 92 | 93 | def start_trial(self): 94 | vendor = get_vendor() 95 | if vendor.keys['publishable_key'] is not None and vendor.keys['secret_key'] is not None: 96 | self.plan_id = vendor.get_default_trial_plan_id() 97 | trial_days = current_app.config['TRIAL_PERIOD_IN_DAYS'] 98 | self.trial_expiration = DateTime.today() + TimeDelta(days = trial_days) 99 | self.account_status = AccountStatus.trial.value 100 | str_valid_time = self.trial_expiration.strftime('%d, %b %Y %H:%M') 101 | self.account_status_text = ('Your trial is activated and valid till ' + 102 | str_valid_time) 103 | else: 104 | self.account_status_text = ('Account was not activated because payment vendors keys have not been provided') 105 | db.session.add(self) 106 | db.session.flush() 107 | if self.account_status == AccountStatus.trial.value: 108 | account_event = self.create_history_event(EventType.trial_started, 109 | 'Started a trial, expiration date is ' + str_valid_time) 110 | -------------------------------------------------------------------------------- /app/DAL/models/role_module.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from sqlalchemy.dialects.postgresql import UUID 3 | from flask_sqlalchemy import SQLAlchemy 4 | from sqlalchemy_utils import UUIDType 5 | import uuid 6 | 7 | from app import db 8 | 9 | 10 | class Role(db.Model): 11 | __tablename__ = 'role' 12 | id = db.Column(UUID(as_uuid=True), 13 | primary_key=True, default=lambda: uuid.uuid4().hex) 14 | is_default = db.Column(db.Boolean, default=False, server_default='f') 15 | name = db.Column(db.String(64), unique=True) 16 | users = db.relationship('User', backref='role', lazy='dynamic') 17 | -------------------------------------------------------------------------------- /app/DAL/models/user_module.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import uuid 3 | from flask import current_app, request, url_for 4 | from flask_login import UserMixin 5 | from flask_bcrypt import generate_password_hash, check_password_hash 6 | from flask_sqlalchemy import SQLAlchemy 7 | from sqlalchemy.dialects.postgresql import UUID 8 | from sqlalchemy_utils import UUIDType 9 | from itsdangerous import TimedJSONWebSignatureSerializer as Serializer 10 | 11 | from app import login_manager 12 | from app import db 13 | 14 | from app.DAL.models.role_module import Role 15 | from app.DAL.models.account_module import Account 16 | 17 | class User(UserMixin, db.Model): 18 | __tablename__ = 'user' 19 | id = db.Column(UUID(as_uuid=True), 20 | primary_key=True, default=lambda: uuid.uuid4().hex) 21 | email = db.Column(db.String(64), unique=True) 22 | username = db.Column(db.String(64)) 23 | password_hash = db.Column(db.String(128)) 24 | role_id = db.Column(UUID(as_uuid=True), db.ForeignKey('role.id')) 25 | confirmed = db.Column(db.Boolean, default=False) 26 | subscribed = db.Column(db.Boolean, default=False) 27 | account_id = db.Column(UUID(as_uuid=True), db.ForeignKey('account.id')) 28 | account = db.relationship('Account', back_populates='user') 29 | 30 | def __init__(self, **kwargs): 31 | super(User, self).__init__(**kwargs) 32 | if self.role is None: 33 | if self.email == current_app.config['ADMIN_EMAIL']: 34 | self.role = Role.query.filter_by(name='Admin').first() 35 | else: 36 | default_role = Role.query.filter_by(is_default=True).first() 37 | self.role = default_role 38 | if self.account == None: 39 | self.account = Account() 40 | 41 | @login_manager.user_loader 42 | def load_user(user_id): 43 | return User.query.get(uuid.UUID(user_id)) 44 | 45 | def set_password(self, password): 46 | salted_pwd = (password + self.email + current_app.config['SECRET_SALT']).encode('utf-8') 47 | self.password_hash = generate_password_hash(salted_pwd).decode('utf-8') 48 | 49 | def verify_password(self, password): 50 | salted_pwd = (password + self.email + current_app.config['SECRET_SALT']).encode('utf-8') 51 | return check_password_hash(self.password_hash, salted_pwd) 52 | 53 | def generate_confirmation_token(self, expiration=3600): 54 | s = Serializer(current_app.config['SECRET_KEY'], expiration) 55 | return s.dumps({'confirm': self.id.__str__()}).decode('utf-8') 56 | 57 | def generate_reset_token(self, expiration=3600): 58 | s = Serializer(current_app.config['SECRET_KEY'], expiration) 59 | return s.dumps({'reset': self.id.__str__()}).decode('utf-8') 60 | 61 | def confirm(self, token): 62 | s = Serializer(current_app.config['SECRET_KEY']) 63 | try: 64 | data = s.loads(token.encode('utf-8')) 65 | except: 66 | return False 67 | if data.get('confirm') != self.id.__str__(): 68 | return False 69 | self.confirmed = True 70 | db.session.add(self) 71 | return True 72 | 73 | def ping(self): 74 | self.last_seen = datetime.utcnow() 75 | db.session.add(self) 76 | -------------------------------------------------------------------------------- /app/DAL/services/account_dal.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from datetime import datetime 3 | from dateutil.relativedelta import * 4 | from ast import literal_eval 5 | from sqlalchemy import func 6 | from sqlalchemy import text 7 | from flask_login import current_user 8 | from app import db 9 | from app.utils.global_functions import get_config_var 10 | from app.DAL.models.account_module import Account 11 | from app.DAL.constants.events import EventType 12 | 13 | 14 | # to-do: paging 15 | def get_account(): 16 | return Account.query.filter_by(id = current_user.account_id).first() 17 | 18 | def update_account_paid(subscription_id, paid, interval, interval_count, period_end, amount, currency): 19 | if subscription_id is None or subscription_id == '': 20 | return { 21 | 'result': False 22 | } 23 | try: 24 | account = Account.query.filter_by(subscription_id = subscription_id).first() 25 | # It may be None when account is just created but didn't saved in the database 26 | if account is not None: 27 | print('Account ID:', account.id) 28 | if paid: 29 | # Update any data you want 30 | result = {'result': True} 31 | # Create new account history event 32 | next_payment = datetime.fromtimestamp(period_end) 33 | message_event = '{3}: Your payment succeed: {0} {1}.' 34 | message_text = message_event + ' Your next payment will be on {2}.' 35 | message = message_text.format(amount, currency, next_payment.strftime('%d, %b %Y'), 36 | datetime.now().strftime('%d, %b %Y')) 37 | account.create_history_event(EventType.subscription_paid, message) 38 | 39 | db.session.commit() 40 | return result 41 | else: 42 | print('Account is None') 43 | except Exception as ex: 44 | # to-do: log 45 | print(ex) 46 | return { 47 | 'result': False 48 | } -------------------------------------------------------------------------------- /app/DAL/services/auth_forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextField 3 | from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo 4 | from wtforms import ValidationError 5 | from wtforms.widgets import TextInput 6 | from app.DAL.models.user_module import User 7 | 8 | 9 | class VueBoundText(TextInput): 10 | def __call__(self, field, **kwargs): 11 | for key in list(kwargs): 12 | if key.startswith('v_'): 13 | kwargs['v-' + key[2:]] = kwargs.pop(key) 14 | return super(VueBoundText, self).__call__(field, **kwargs) 15 | 16 | class RegistrationForm(FlaskForm): 17 | email = StringField('Email', validators=[DataRequired(), Length(1), 18 | Email()]) 19 | username = StringField('Username (visible by others)', validators=[ 20 | DataRequired(), 21 | Length(1, 128), 22 | Regexp('^[A-Za-z ][A-Za-z0-9_. ]*$', 0, 23 | 'Usernames must have only letters, numbers, dots, spaces or ' 24 | 'underscores')]) 25 | password = PasswordField('Password', validators=[ 26 | DataRequired(), EqualTo('password2', message='Passwords must match.')]) 27 | password2 = PasswordField('Confirm password', validators=[DataRequired()]) 28 | submit = SubmitField('Register') 29 | 30 | def validate_email(self, field): 31 | if User.query.filter_by(email=field.data).first(): 32 | raise ValidationError('Email already registered.') 33 | 34 | # Uncomment if you need to keep usernames unique 35 | ''' 36 | def validate_username(self, field): 37 | if User.query.filter_by(username=field.data).first(): 38 | raise ValidationError('Username already in use.') 39 | ''' 40 | 41 | class LoginForm(FlaskForm): 42 | email = StringField('Email', validators=[DataRequired(), Length(1, 64), 43 | Email()]) 44 | password = PasswordField('Password', validators=[DataRequired()]) 45 | remember_me = BooleanField('Keep me logged in') 46 | submit = SubmitField('Log In') 47 | 48 | class ChangePasswordForm(FlaskForm): 49 | old_password = PasswordField('Old password', validators=[DataRequired()]) 50 | password = PasswordField('New password', validators=[ 51 | DataRequired(), EqualTo('password2', message='Passwords must match.')]) 52 | password2 = PasswordField('Confirm new password', 53 | validators=[DataRequired()]) 54 | submit = SubmitField('Update Password') 55 | 56 | 57 | class PasswordResetRequestForm(FlaskForm): 58 | email = StringField('Email', validators=[DataRequired(), Length(1, 64), 59 | Email()]) 60 | submit = SubmitField('Reset Password') 61 | 62 | 63 | class PasswordResetForm(FlaskForm): 64 | password = PasswordField('New Password', validators=[ 65 | DataRequired(), EqualTo('password2', message='Passwords must match')]) 66 | password2 = PasswordField('Confirm password', validators=[DataRequired()]) 67 | submit = SubmitField('Reset Password') 68 | 69 | 70 | class ChangeEmailForm(FlaskForm): 71 | email = StringField('New Email', validators=[DataRequired(), Length(1, 64), 72 | Email()]) 73 | password = PasswordField('Password', validators=[DataRequired()]) 74 | submit = SubmitField('Update Email Address') 75 | 76 | def validate_email(self, field): 77 | if User.query.filter_by(email=field.data).first(): 78 | raise ValidationError('Email already registered.') 79 | -------------------------------------------------------------------------------- /app/DAL/services/vendor.py: -------------------------------------------------------------------------------- 1 | from sys import exc_info 2 | from abc import ABCMeta, abstractmethod 3 | from app.utils.global_functions import get_config_var 4 | import stripe 5 | 6 | class Vendor_base: 7 | 8 | keys = None 9 | 10 | def init_app(self, app): 11 | self.application = app 12 | 13 | @abstractmethod 14 | def get_vendor_name(self): 15 | pass 16 | 17 | @abstractmethod 18 | def init_keys(self): 19 | pass 20 | 21 | @abstractmethod 22 | def get_plans(self): 23 | pass 24 | 25 | @abstractmethod 26 | def get_default_trial_plan_id(self): 27 | pass 28 | 29 | @abstractmethod 30 | def get_public_key(self): 31 | pass 32 | 33 | @abstractmethod 34 | def get_secret_key(self): 35 | pass 36 | 37 | @abstractmethod 38 | def save_payment_method_info(self, user_email, **kwargs): 39 | pass 40 | 41 | @abstractmethod 42 | def create_subscription(self, plan_id, **kwargs): 43 | pass 44 | 45 | @abstractmethod 46 | def cancel_subscription(self, subscription_id): 47 | pass 48 | 49 | @abstractmethod 50 | def pause_subscription(self, subscription_id): 51 | pass 52 | 53 | @abstractmethod 54 | def resume_subscription(self, plan_id, **kwargs): 55 | pass 56 | 57 | 58 | class Vendor_Stripe(Vendor_base): 59 | 60 | def __init__(self, app): 61 | self.init_app(app) 62 | 63 | def get_vendor_name(self): 64 | return 'stripe' 65 | 66 | def init_keys(self): 67 | self.keys = { 68 | 'publishable_key': get_config_var('STRIPE_PUBLISHABLE_KEY', self.application), 69 | 'secret_key': get_config_var('STRIPE_SECRET_KEY', self.application), 70 | } 71 | stripe.api_key = self.keys['secret_key'] 72 | 73 | def map_plan_data(self, plan): 74 | new_plan = {} 75 | new_plan['id'] = plan['id'] 76 | new_plan['name'] = plan['nickname'] 77 | new_plan['active'] = plan['active'] 78 | new_plan['price'] = "{:0.0f}".format(plan['amount']/100) 79 | new_plan['currency'] = plan['currency'].upper() 80 | new_plan['interval'] = plan['interval'] 81 | new_plan['interval_count'] = plan['interval_count'] 82 | if plan['metadata'] is not None: 83 | new_plan['features'] = [] 84 | features = {} 85 | if 'description' in plan['metadata']: 86 | new_plan['description'] = plan['metadata']['description'] 87 | for key in dict(plan['metadata']): 88 | if key.startswith('feature'): 89 | parts = key.split(':') 90 | if len(parts) == 2: 91 | if parts[1].isnumeric: 92 | features[int(parts[1])] = plan['metadata'][key] 93 | 94 | features = dict(sorted(features.items())) 95 | new_plan['features'] = list(features.values()) 96 | #new_plan['features'].insert(int(parts[1]), plan['metadata'][key]) 97 | # Deterime if this plan is a default for a trial 98 | if 'default' in plan['metadata']: 99 | if plan['metadata']['default'] == 'trial': 100 | new_plan['default'] = 'trial' 101 | return new_plan 102 | 103 | def get_plans(self): 104 | raw_plans = stripe.Plan.list() 105 | mapped_plans = list(map(self.map_plan_data, raw_plans.data)) 106 | return mapped_plans 107 | 108 | def get_default_trial_plan_id(self): 109 | plans_list = stripe.Plan.list() 110 | plan_default = next((plan for plan in plans_list if (plan.get('metadata') and 111 | plan.get('metadata').get('default') == 'trial')), None) 112 | if plan_default is not None: 113 | return plan_default['id'] 114 | else: 115 | return None 116 | 117 | def get_public_key(self): 118 | return self.keys['publishable_key'] 119 | 120 | def get_secret_key(self): 121 | return self.keys['secret_key'] 122 | 123 | def save_payment_method_info(self, user_email, **kwargs): 124 | customer = stripe.Customer.create( 125 | email = user_email, 126 | source = kwargs['token'] 127 | ) 128 | return customer.id if customer != None else None 129 | 130 | def create_subscription(self, plan_id, **kwargs): 131 | subscription = None 132 | customer_id = kwargs['payment_method_info'] 133 | try: 134 | # Firstly cancel any existing subscription 135 | 136 | if kwargs['current_subscription_id']: 137 | subscription = stripe.Subscription.retrieve(kwargs['current_subscription_id']) 138 | if plan_id == kwargs['current_plan_id'] and kwargs['account_status'] == 'paused': 139 | # Just reactivate 140 | subscription.cancel_at_period_end = False 141 | subscription.save() 142 | else: 143 | # Delete an old subscription and create a new one 144 | if (subscription != None): 145 | subscription.delete() 146 | subscription = stripe.Subscription.create( 147 | customer = customer_id, 148 | items = [ 149 | { 150 | 'plan': plan_id, 151 | } 152 | ] 153 | ) 154 | except Exception as ex: 155 | # to-do: log exception sys.exc_info()[0] 156 | print(ex) 157 | if subscription is None: 158 | return { 159 | 'result': False, 160 | 'message': 'Something went wrong and we could not charge you. Please make sure you provided valid payment information.' 161 | } 162 | 163 | return { 164 | 'result': True, 165 | 'subscription_id': subscription.id, 166 | 'amount': "{:.2f}".format(subscription['items']['data'][0]['plan']['amount'] / 100), 167 | 'currency': subscription['items']['data'][0]['plan']['currency'].upper(), 168 | 'period_end': subscription['current_period_end'] 169 | } 170 | 171 | def cancel_subscription(self, subscription_id): 172 | result = True 173 | message = '' 174 | try: 175 | subscription = stripe.Subscription.retrieve(subscription_id) 176 | subscription.cancel_at_period_end = True 177 | subscription.save() # or use subscription.delete() 178 | except: 179 | # to-do: log exception sys.exc_info()[0] 180 | print(exc_info()[0]) 181 | result = False 182 | return { 183 | 'result': result 184 | } 185 | 186 | def resume_subscription(self, plan_id, **kwargs): 187 | return self.create_subscription(self, plan_id, **kwargs) -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | from pathlib import Path 3 | 4 | from flask import Flask 5 | from flask_login import LoginManager 6 | from flask_wtf.csrf import CSRFProtect 7 | from flask_sqlalchemy import SQLAlchemy 8 | from flask_mail import Mail 9 | from flask_alembic import Alembic 10 | 11 | from config import ConfigHelper 12 | 13 | db = SQLAlchemy() 14 | login_manager = LoginManager() 15 | csrf = CSRFProtect() 16 | mail = Mail() 17 | alembic = Alembic() 18 | app_path = '' 19 | vendor = None 20 | 21 | def get_vendor(): 22 | return vendor 23 | 24 | def create_app(): 25 | 26 | app = Flask(__name__) 27 | redefine_delimiters(app) 28 | dist_folder = os.path.abspath(os.path.join(app.root_path,"../static")) 29 | app.static_folder = dist_folder 30 | app.static_url_path='/static' 31 | app_path = app.root_path 32 | app.url_map.strict_slashes = False 33 | app.config.from_object(ConfigHelper.set_config(sys.argv)) 34 | initialize_libraries(app) 35 | init_filters(app) 36 | init_global_functions(app) 37 | register_blueprints(app) 38 | register_error_handlers(app) 39 | init_payment_vendor(app) 40 | return app 41 | 42 | def redefine_delimiters(app): 43 | jinja_options = app.jinja_options.copy() 44 | jinja_options.update(dict( 45 | block_start_string='{%', 46 | block_end_string='%}', 47 | variable_start_string='${', 48 | variable_end_string='}', 49 | comment_start_string='{#', 50 | comment_end_string='#}', 51 | )) 52 | app.jinja_options = jinja_options 53 | 54 | def init_app_path(path): 55 | global app_path 56 | app_path = path 57 | 58 | def init_db(option, app): 59 | import app.utils.dbscaffold as dbscaffold 60 | dbscaffold.reinit_db(option) 61 | 62 | def init_payment_vendor(app): 63 | global vendor 64 | from app.DAL.services.vendor import Vendor_Stripe, Vendor_base # to-do it dynamically 65 | vendor = Vendor_Stripe(app) # to-do: vendor is selected based on config 66 | vendor.init_keys() 67 | 68 | def initialize_libraries(app): 69 | db.init_app(app) 70 | login_manager.init_app(app) 71 | login_manager.login_view = 'auth.login_page' 72 | csrf.init_app(app) 73 | mail.init_app(app) 74 | alembic.init_app(app) 75 | 76 | def register_blueprints(app): 77 | from app.blueprints.auth_blueprint import auth_blueprint 78 | from app.blueprints.dashboard_blueprint import dashboard_blueprint 79 | from app.blueprints.billing_blueprint import billing_blueprint 80 | 81 | blueprints = [auth_blueprint, dashboard_blueprint, billing_blueprint] # Add a new blueprint here # to-do: automate it 82 | for blueprint in blueprints: 83 | app.register_blueprint(blueprint) 84 | 85 | 86 | def get_path_to_include(relative_path): 87 | return os.path.abspath(os.path.join(app_path, relative_path)) 88 | 89 | def init_filters(app): 90 | from app.utils import filters 91 | filters.init(app) 92 | 93 | def init_global_functions(app): 94 | from app.utils import global_functions 95 | global_functions.init(app) 96 | 97 | def register_error_handlers(app): 98 | app.register_error_handler(404, page_not_found) 99 | app.register_error_handler(500, page_server_error) 100 | 101 | def page_not_found(e): 102 | from app.utils import error_handler 103 | return error_handler.app_error(error_title='PAGE NOT FOUND', error_text='The page you asked for not found...'), 404 104 | 105 | def page_server_error(e): 106 | from app.utils import error_handler 107 | return error_handler.app_error(error_title='CRITICAL ERROR', error_text='Something went wrong... please try again.'), 500 108 | 109 | -------------------------------------------------------------------------------- /app/api/account_api.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import stripe 3 | from flask_restplus import Namespace, Resource, fields 4 | from flask import jsonify, request, current_app 5 | from flask_marshmallow import Marshmallow 6 | from marshmallow import fields, validate 7 | from flask_login import login_user, logout_user, login_required, current_user 8 | 9 | 10 | account_api = Namespace('account', path='/api/account') 11 | 12 | # This endpoint will hit when next subscription paid - by Stripe. 13 | @account_api.route('/stripepaid') 14 | class AccountSubscriptionPaidUpdate(Resource): 15 | def post(self): 16 | sig_header = request.headers.environ.get('HTTP_STRIPE_SIGNATURE') 17 | event = None 18 | interval = '' 19 | interval_count = 0 20 | try: 21 | event = stripe.Webhook.construct_event( 22 | request.get_data(), sig_header, current_app.config['STRIPE_ENDPOINT_SECRET'] 23 | ) 24 | except ValueError as e: 25 | # Invalid payload 26 | return 400 27 | except stripe.error.SignatureVerificationError as e: 28 | # Invalid signature 29 | return 400 30 | try: 31 | # Get plan data 32 | interval = account_api.payload['data']['object']['lines']['data'][0]['plan']['interval'] 33 | interval_count = account_api.payload['data']['object']['lines']['data'][0]['plan']['interval_count'] 34 | except Exception as e: 35 | print('ERROR: Could not get plan data...') 36 | 37 | # Update account data + create new account history data 38 | print('STRIPE update: ', account_api.payload['data']['object']['subscription'], 39 | account_api.payload['data']['object']['paid'], interval, interval_count) 40 | account_dal.update_account_paid(account_api.payload['data']['object']['subscription'], 41 | account_api.payload['data']['object']['paid'], interval, interval_count, 42 | account_api.payload['data']['object']['period_end'], 43 | account_api.payload['data']['object']['amount_paid'], 44 | account_api.payload['data']['object']['currency']) 45 | 46 | 47 | return 200 48 | -------------------------------------------------------------------------------- /app/api/auth_api.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from urllib import parse 3 | from flask import render_template, redirect, request, url_for, flash, jsonify, current_app 4 | from flask_login import login_user, logout_user, login_required, current_user 5 | from app import db 6 | from app.DAL.models import user_module 7 | from app.DAL.models import account_module 8 | from app.utils.email import send_email 9 | from app.DAL.services.auth_forms import LoginForm, RegistrationForm, ChangePasswordForm,\ 10 | PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm 11 | 12 | import app.utils.error_handler as error_handler 13 | from app.blueprints.auth_blueprint import auth_blueprint 14 | 15 | 16 | # This code is to show you how to redirect to error page - if it's not 404 or 500, but just something went wrong. 17 | @auth_blueprint.route('/testerror', methods=['GET']) 18 | def testerror(): 19 | return error_handler.app_error('Some unknown error', 'some error text') 20 | 21 | @auth_blueprint.route('/login', methods=['GET']) 22 | def login_page(): 23 | if current_user.is_authenticated: 24 | if not current_user.confirmed: 25 | return redirect(url_for('auth.confirm_email', userid = str(current_user.id))) 26 | return render_template('/auth/login.html', company_name=current_app.config['COMPANY_NAME']) 27 | 28 | @auth_blueprint.route('/login', methods=['POST']) 29 | def login_api(): 30 | form = LoginForm() 31 | if form.validate_on_submit(): 32 | user = user_module.User.query.filter_by(email=form.email.data).first() 33 | if user and user.verify_password(form.password.data): 34 | login_user(user, form.remember_me.data) 35 | prev_url = '' 36 | if request.referrer: 37 | prev_query = parse.urlparse(request.referrer).query 38 | if prev_query and parse.parse_qs(prev_query).get('next'): 39 | prev_url = parse.parse_qs(prev_query).get('next')[0] 40 | redirect_url = prev_url or url_for('dashboard.index_page_without_params') 41 | return jsonify({'result': True, 'redirect': redirect_url}) 42 | return jsonify({'result': False, 'errors': ['Invalid username or password.']}) 43 | else: 44 | return jsonify({'result': False, 'errors': ['Invalid username or password.']}) 45 | 46 | 47 | @auth_blueprint.route('/logout') 48 | @login_required 49 | def logout(): 50 | logout_user() 51 | return redirect(url_for('auth.login_page')) 52 | 53 | 54 | @auth_blueprint.route('/register', methods=['GET']) 55 | def register_page(): 56 | return render_template('/auth/register.html', company_name=current_app.config['COMPANY_NAME']) 57 | 58 | @auth_blueprint.route('/register', methods=['POST']) 59 | def register_api(): 60 | form = RegistrationForm() 61 | if form.validate(): 62 | user = user_module.User(email = form.email.data, 63 | username = form.username.data, 64 | confirmed = False) 65 | user.set_password(form.password.data) 66 | db.session.add(user) 67 | db.session.commit() 68 | token = user.generate_confirmation_token() 69 | send_email(user.email, 'Please confirm Your Email', '/non_auth/confirmation', user=user, token=token, company_name=current_app.config['COMPANY_NAME']) 70 | confirm_email = url_for('auth.confirm_email', userid = str(user.id)) 71 | return jsonify({'result': True, 'redirect': confirm_email}) 72 | else: 73 | return jsonify({'result': False, 'errors': form.errors}) 74 | 75 | # This page is open only after registration by the client code itself, or in a case when unconfirmed user tries to login 76 | @auth_blueprint.route('/confirmemail/', methods=['GET']) 77 | def confirm_email(userid): 78 | user = user_module.User.query.filter_by(id=uuid.UUID(userid).hex).first() 79 | if user: 80 | flash('We\'ve sent you the confirmation email, please open it and click on the confirmation link.') 81 | return render_template("/auth/confirmemail.html", userid = userid, company_name=current_app.config['COMPANY_NAME']) 82 | return error_handler.app_error('User identification error', 'Sorry but user not found. Please login or register again.') 83 | 84 | @auth_blueprint.route('/confirm//', methods=['GET']) 85 | def confirm(token, userid): 86 | user = user_module.User.query.filter_by(id=uuid.UUID(userid).hex).first() 87 | if user: 88 | if not user.confirmed: 89 | if user.confirm(token): 90 | db.session.commit() 91 | # User is confirmed, but we have to logout to make sure that THIS exact user will login then. 92 | logout_user() 93 | return redirect(url_for('auth.confirmed')) 94 | else: 95 | return error_handler.app_error('User identification error', 'Sorry but user not found. Please login or register again.') 96 | 97 | @auth_blueprint.route('/confirmed') 98 | def confirmed(): 99 | return render_template('/auth/confirmed.html', company_name=current_app.config['COMPANY_NAME']) 100 | 101 | @auth_blueprint.route('/resendconfirm/') 102 | def resend_confirmation(userid): 103 | user = user_module.User.query.filter_by(id=uuid.UUID(userid).hex).first() 104 | if user: 105 | token = user.generate_confirmation_token() 106 | send_email(user.email, 'Please confirm Your Email', '/non_auth/confirmation', user=user, token=token, company_name=current_app.config['COMPANY_NAME']) 107 | flash('We\'ve resent you the confirmation email. Please open your mail and click the link inside.') 108 | return render_template("/auth/confirmemail.html", userid = userid, company_name=current_app.config['COMPANY_NAME']) 109 | return error_handler.app_error('User identification error', 'Sorry but user not found. Please login or register again.') -------------------------------------------------------------------------------- /app/api/billing_api.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, request, url_for, flash, jsonify, current_app 2 | from flask_login import login_user, logout_user, login_required, current_user 3 | from datetime import datetime as DateTime, timedelta as TimeDelta 4 | from sqlalchemy import desc 5 | import json 6 | 7 | from app import db 8 | from app.blueprints.billing_blueprint import billing_blueprint 9 | from app.DAL.models.account_module import Account, AccountHistory 10 | from app.DAL.constants.account_status import AccountStatus, ValidStatus 11 | from app.DAL.constants.events import EventType 12 | from app.utils.custom_login_required_api_decorator import login_required_for_api 13 | 14 | from app import get_vendor 15 | 16 | 17 | # API routes 18 | @billing_blueprint.route('/api/billing/account', methods=['GET']) 19 | @login_required_for_api 20 | def get_user_account(): 21 | # Determine if account is valid 22 | current_user.account.check_account_status() 23 | 24 | return jsonify({ 25 | 'result': True, 26 | 'info': { 27 | 'payment_method_info': current_user.account.payment_method_info, 28 | 'payment_method_text': current_user.account.payment_method_text, 29 | 'account_status_text': current_user.account.account_status_text, 30 | 'account_status': current_user.account.account_status, 31 | 'valid_status': current_user.account.valid_status, 32 | 'trial_expiration': current_user.account.trial_expiration, 33 | 'payment_expiration': current_user.account.payment_expiration, 34 | 'plan_id': current_user.account.plan_id, 35 | 'plan_name': current_user.account.plan_name, 36 | 'vendor_name': current_user.account.vendor_name, 37 | 'payment_method': current_user.account.payment_method 38 | } 39 | }) 40 | 41 | @billing_blueprint.route('/api/billing/history', methods=['GET']) 42 | @login_required_for_api 43 | def get_user_billing_history(): 44 | data = current_user.account.account_history.order_by(desc(AccountHistory.date)).limit(100).all() 45 | history_data = json.loads(json.dumps([row.toDict() for row in data])) 46 | return jsonify({ 47 | 'result': True, 48 | 'info': history_data 49 | }) 50 | 51 | 52 | @billing_blueprint.route('/api/billing/plan/list', methods=['GET']) 53 | @login_required_for_api 54 | def get_current_plans(): 55 | vendor = get_vendor() 56 | plans = vendor.get_plans() 57 | return jsonify({ 58 | 'result': True, 59 | 'plans': plans 60 | }) 61 | 62 | # Saves the method information for the current user 63 | @billing_blueprint.route('/api/billing/paymentMethod', methods=['POST']) 64 | @login_required_for_api 65 | def save_payment_method(): 66 | vendor = get_vendor() # to-do: make it based on request.json['vendor'] what is currently 'stripe' ?? 67 | skey = vendor.get_secret_key() 68 | token = request.json['token'] 69 | method_name = request.json['name'] 70 | method_text = request.json['text'] 71 | 72 | event = EventType.payment_method_selected if current_user.account.payment_method is None else EventType.payment_method_changed 73 | event_text = 'You selected ' if current_user.account.payment_method is None else 'You changed ' 74 | 75 | payment_method_info = vendor.save_payment_method_info(current_user.email, **{'token': token}) 76 | vendor_name = vendor.get_vendor_name() # what is primary - method or vendor? Probably method. So method should have this info. 77 | 78 | current_user.account.vendor_name = vendor_name 79 | current_user.account.payment_method = method_name 80 | current_user.account.payment_method_info = payment_method_info 81 | current_user.account.payment_method_text = method_text 82 | 83 | event_text = event_text + ' a payment method. ' + method_text 84 | current_user.account.create_history_event(event, event_text) 85 | 86 | db.session.add(current_user) 87 | db.session.commit() 88 | 89 | return jsonify({ 90 | 'result': True, 91 | 'info': payment_method_info 92 | }) 93 | 94 | # Start subscription and buy it 95 | @billing_blueprint.route('/api/billing/subscription', methods=['POST']) 96 | @login_required_for_api 97 | def start_subscription(): 98 | vendor = get_vendor() 99 | skey = vendor.get_secret_key() 100 | 101 | event = EventType.subscription_paid 102 | if current_user.account.subscription_id != None: 103 | if request.json['plan_id'] == current_user.account.plan_id and current_user.account.account_status == 'paused': 104 | event = EventType.subscription_resumed 105 | 106 | subscription_data = vendor.create_subscription(request.json['plan_id'], 107 | **{ 108 | 'payment_method_info': current_user.account.payment_method_info, 109 | 'current_plan_id': current_user.account.plan_id, 110 | 'current_subscription_id': current_user.account.subscription_id, 111 | 'account_status': current_user.account.account_status 112 | } 113 | ) 114 | if subscription_data['result']: 115 | next_payment = DateTime.fromtimestamp(subscription_data['period_end']) 116 | message_event = '{3}: Your payment succeed: {0} {1}.' if event == EventType.subscription_paid else 'Your subscription is resumed.' 117 | message_text = message_event + ' Your next payment will be on {2}.' 118 | message = message_text.format(subscription_data['amount'], subscription_data['currency'], next_payment.strftime('%d, %b %Y'), 119 | DateTime.now().strftime('%d, %b %Y')) 120 | current_user.account.create_history_event(event, message) 121 | 122 | # Update account data 123 | current_user.account.plan_id = request.json['plan_id'] 124 | current_user.account.plan_name = request.json['plan_name'] 125 | current_user.account.subscription_id = subscription_data['subscription_id'] 126 | current_user.account.payment_expiration = next_payment 127 | current_user.account.account_status = AccountStatus.paid.value 128 | current_user.account.valid_status = ValidStatus.valid.value 129 | current_user.account.account_status_text = message 130 | else: 131 | message = 'Your payment failed: ' + subscription_data['message'] 132 | current_user.account.create_history_event(EventType.subscription_failed, message) 133 | current_user.account.check_account_status() # We need to check and change if trial is expired already. 134 | 135 | db.session.add(current_user.account) 136 | db.session.commit() 137 | 138 | return jsonify({ 139 | 'result': subscription_data['result'], 140 | 'account_status':current_user.account.account_status, 141 | 'account_status_text': message, 142 | 'payment_method_text': current_user.account.payment_method_text 143 | }) 144 | 145 | # Cancel/pause subscription 146 | @billing_blueprint.route('/api/billing/subscription/cancelpause', methods=['POST']) 147 | @login_required_for_api 148 | def cancel_subscription(): 149 | vendor = get_vendor() 150 | skey = vendor.get_secret_key() 151 | if current_user.account.plan_id == None or (current_user.account.account_status != AccountStatus.paid.value and 152 | current_user.account.account_status != AccountStatus.paused.value): 153 | return jsonify({ 154 | 'result': False, 155 | 'message': 'You don\'t have any live subscription.' 156 | }) 157 | cancel_result = vendor.cancel_subscription(current_user.account.subscription_id) 158 | pause_request = True if request.json != None and 'pause' in request.json else False 159 | if cancel_result['result'] == False: 160 | # to-do: log message 161 | pass 162 | else: 163 | if pause_request: 164 | current_user.account.account_status = AccountStatus.paused.value 165 | current_user.account.account_status_text = 'The subscription was paused.' 166 | else: 167 | current_user.account.account_status = AccountStatus.cancelled.value 168 | current_user.account.account_status_text = 'The subscription was cancelled.' 169 | current_user.account.subscription_id = None 170 | if current_user.account.payment_expiration < DateTime.now(): 171 | current_user.account.payment_expiration = None 172 | current_user.account.valid_status = ValidStatus.invalid.value 173 | current_user.account.plan_name = '' 174 | current_user.account.plan_id = None 175 | else: 176 | current_user.account.account_status_text += (' But you still have an access to the service until ' + 177 | current_user.account.payment_expiration.strftime('%d, %b %Y') + '.') 178 | event = EventType.subscription_paused if pause_request else EventType.subscription_cancelled 179 | current_user.account.create_history_event(event, current_user.account.account_status_text) 180 | db.session.add(current_user.account) 181 | db.session.commit() 182 | 183 | return jsonify({ 184 | 'result': cancel_result['result'], 185 | 'account_status':current_user.account.account_status, 186 | 'account_status_text': current_user.account.account_status_text 187 | }) -------------------------------------------------------------------------------- /app/api/dashboard_api.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, request, url_for, flash, jsonify, current_app 2 | from flask_login import login_user, logout_user, current_user, login_required 3 | from app import db 4 | from app import get_vendor 5 | from app.blueprints.dashboard_blueprint import dashboard_blueprint 6 | from app.utils.custom_login_required_api_decorator import login_required_for_api 7 | 8 | 9 | @dashboard_blueprint.route('/', methods=['GET']) 10 | @login_required 11 | def index_page_without_params(): 12 | return redirect('/sample') 13 | 14 | @dashboard_blueprint.route('/', methods=['GET']) 15 | @login_required 16 | def index_page(path): 17 | vendor = get_vendor() 18 | return render_template('dashboard.html', company_name=current_app.config.get('COMPANY_NAME'), vendor_pkey = vendor.get_public_key()) 19 | 20 | 21 | # API routes 22 | @dashboard_blueprint.route('/api/userdata', methods=['GET']) 23 | @login_required_for_api 24 | def get_current_user_data(): 25 | return jsonify({ 26 | 'result': True, 27 | 'userdata': { 28 | 'username': current_user.username, 29 | 'subscribed': current_user.subscribed 30 | } 31 | }) 32 | 33 | @dashboard_blueprint.route('/api/userdata', methods=['POST']) 34 | @login_required_for_api 35 | def update_current_user_data(): 36 | user_data = request.get_json(); 37 | current_user.username = user_data['username'] 38 | current_user.subscribed = user_data['subscribed'] 39 | db.session.add(current_user) 40 | db.session.commit() 41 | return jsonify({ 42 | 'result': True, 43 | }) -------------------------------------------------------------------------------- /app/blueprints/account_blueprint.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaravelKit/saas-base/048bdd2dbe23bd67f9780d218e0569a4a96708c1/app/blueprints/account_blueprint.py -------------------------------------------------------------------------------- /app/blueprints/auth_blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth_blueprint = Blueprint('auth', __name__, template_folder='templates') 4 | 5 | from app.api import auth_api -------------------------------------------------------------------------------- /app/blueprints/billing_blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | billing_blueprint = Blueprint('billing', __name__) 4 | 5 | from app.api import billing_api -------------------------------------------------------------------------------- /app/blueprints/dashboard_blueprint.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | dashboard_blueprint = Blueprint('dashboard', __name__, template_folder='templates') 4 | 5 | from app.api import dashboard_api -------------------------------------------------------------------------------- /app/js/appAuth.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import { httpService } from './common/httpService.js'; 3 | 4 | var app = new Vue({ 5 | el: '#app_auth', 6 | data: { 7 | errors: [], 8 | data: { 9 | username: null, 10 | email: null, 11 | password: null, 12 | password2: null, 13 | remember_me: true 14 | }, 15 | valid: { 16 | username: false, 17 | email: false, 18 | password: false, 19 | password2: false 20 | }, 21 | resend_success: false 22 | }, 23 | methods:{ 24 | handleResponse: function(self, response){ 25 | self.errors = []; 26 | if (response && response.data){ 27 | for (var key in response.data.errors){ 28 | self.errors = self.errors.concat(response.data.errors[key]); 29 | } 30 | if (response.data.redirect){ 31 | window.location.href = response.data.redirect; 32 | } 33 | } 34 | }, 35 | checkRegisterForm: function (e) { 36 | e.preventDefault(); 37 | var self = this; 38 | if (this.data.username && 39 | this.data.password && 40 | this.data.email && 41 | this.validEmail(this.data.email) && 42 | this.data.password == this.data.password2) { 43 | this.valid.username = true; 44 | this.valid.email = true; 45 | this.valid.password = true; 46 | httpService.post('/register', this.data).then(function(response){ 47 | self.handleResponse(self, response); 48 | }).catch(function(err){ 49 | if (err.message){ 50 | self.errors = [err.message]; 51 | } 52 | }); 53 | } else { 54 | this.errors = []; 55 | 56 | if (!this.data.username) { 57 | this.errors.push('Please enter user name.'); 58 | } else { 59 | this.valid.username = true; 60 | } 61 | if (!this.data.email) { 62 | this.errors.push('Please enter your email.'); 63 | } else { 64 | if (!this.validEmail(this.data.email)){ 65 | this.errors.push('Please enter a valid email.'); 66 | } else { 67 | this.valid.email = true; 68 | } 69 | } 70 | if (!this.data.password) { 71 | this.errors.push('Please enter your password.'); 72 | } else { 73 | this.valid.password = true; 74 | } 75 | if (!this.data.password2) { 76 | this.errors.push('Please confirm your password.'); 77 | } else { 78 | this.valid.password2 = true; 79 | } 80 | if (this.data.password != this.data.password2){ 81 | this.errors.push('Passwords should match.'); 82 | } else { 83 | this.valid.password2 = true; 84 | } 85 | } 86 | }, 87 | checkLoginForm: function (e) { 88 | e.preventDefault(); 89 | var self = this; 90 | if (this.data.password && this.data.email && this.validEmail(this.data.email)) { 91 | this.valid.email = true; 92 | this.valid.password = true; 93 | httpService.post('/login', this.data).then(function(response){ 94 | self.handleResponse(self, response); 95 | }).catch(function(err){ 96 | if (err.message){ 97 | self.errors = [err.message]; 98 | } 99 | }); 100 | } else { 101 | this.errors = []; 102 | 103 | if (!this.data.email) { 104 | this.errors.push('Please enter your email.'); 105 | } else { 106 | if (!this.validEmail(this.data.email)){ 107 | this.errors.push('Please enter a valid email.'); 108 | } else { 109 | this.valid.email = true; 110 | } 111 | } 112 | if (!this.data.password) { 113 | this.errors.push('Please enter your password.'); 114 | } else { 115 | this.valid.email = true; 116 | } 117 | } 118 | }, 119 | validEmail: function (email) { 120 | 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,}))$/; 121 | return re.test(email); 122 | } 123 | } 124 | }); -------------------------------------------------------------------------------- /app/js/appDashboard.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Vuex from 'vuex'; 4 | import $ from 'jquery'; 5 | import 'jquery.easing'; 6 | import 'popper.js'; 7 | import 'bootstrap'; 8 | var moment = require('moment'); 9 | import LeftMenu from './leftMenu.js'; 10 | import UserProfile from './views/dashboard/userProfile.vue'; 11 | import PageNotFound from './views/dashboard/pageNotFound.vue'; 12 | import Breadcrumbs from './views/dashboard/breadcrumbs.vue'; 13 | import Billing from './views/billing/billing.vue'; 14 | import Plan from './views/billing/plan.vue'; 15 | import BillingHistory from './views/billing/billingHistory.vue'; 16 | import Summary from './views/billing/summary.vue'; 17 | import PaymentMethod from './views/billing/paymentMethod.vue'; 18 | 19 | import UserRoutes from './userRoutes.js' 20 | 21 | Vue.use(VueRouter); 22 | Vue.use(Vuex); 23 | const store = new Vuex.Store({ 24 | state: { 25 | accountInfo: {}, 26 | accountText: '', 27 | sideBarOpened: true 28 | }, 29 | mutations: { 30 | /** Account/payment information **/ 31 | updatePaymentInfo: function(state, newPaymentInfo) { 32 | state.accountInfo.payment_method_info = newPaymentInfo; 33 | }, 34 | updatePaymentText: function(state, newPaymentText) { 35 | state.accountInfo.payment_method_text = newPaymentText; 36 | }, 37 | updateAccountStatus: function(state, newStatus){ 38 | state.accountInfo.account_status = newStatus; 39 | }, 40 | updateAccountStatusText: function(state, newStatusText){ 41 | state.accountInfo.account_status_text = newStatusText; 42 | }, 43 | updateValidStatus: function(state, newStatus){ 44 | state.accountInfo.valid_status = newStatus; 45 | }, 46 | updatePaymentMethod: function(state, newMethod){ 47 | state.accountInfo.payment_method = newMethod; 48 | }, 49 | updatePlanId: function(state, newPlanId){ 50 | state.accountInfo.plan_id = newPlanId; 51 | }, 52 | updatePlanName: function(state, newPlanName){ 53 | state.accountInfo.plan_name = newPlanName; 54 | }, 55 | updateAccountInfo: function(state, newAccountInfo) { 56 | state.accountInfo = newAccountInfo; 57 | }, 58 | /** Interface **/ 59 | toggleSideBar: function(state){ 60 | state.sideBarOpened = !state.sideBarOpened; 61 | } 62 | } 63 | }); 64 | 65 | var routes = [ 66 | { 67 | path: '/user/profile', 68 | component: UserProfile, 69 | name: 'userProfile', 70 | meta: { 71 | breadcrumb: [ 72 | { name: 'User' }, 73 | { name: 'Profile' } // add 'link' field with name or the route 74 | ] 75 | } 76 | }, 77 | { 78 | path: '/billing', 79 | component: Billing, 80 | name: 'billing', 81 | meta: { 82 | breadcrumb: [ 83 | { name: 'Billing' } 84 | ] 85 | }, 86 | children: [ 87 | {path: '', component: Summary, meta: { 88 | breadcrumb: [{name: 'Billing'}] 89 | }}, 90 | { path: 'plan', 91 | component: Plan, 92 | meta: { 93 | breadcrumb: [{name: 'Billing', link: '/billing'}, {name: 'Plan'}] 94 | } 95 | }, 96 | {path: 'paymentMethod', component: PaymentMethod, meta: { 97 | breadcrumb: [{name: 'Billing', link: '/billing'}, {name: 'Payment methods'}] 98 | }}, 99 | {path: 'billingHistory', component: BillingHistory, meta: { 100 | breadcrumb: [{name: 'Billing', link: '/billing'}, {name: 'Billing history'}] 101 | }} 102 | ] 103 | }, 104 | { path: "*", component: PageNotFound } 105 | ]; 106 | 107 | routes = routes.concat(UserRoutes); 108 | 109 | var router = new VueRouter({ 110 | routes, 111 | mode: 'history', 112 | linkExactActiveClass: 'active' 113 | }); 114 | 115 | var timeFormatter = function(value){ 116 | return (value ? moment(value).format("YYYY-MM-DD HH:mm:ss"): '-- ---'); 117 | }; 118 | Vue.filter('formatDt', timeFormatter); 119 | 120 | new Vue({ 121 | el: '#dashboardApp', 122 | components: { 123 | 'leftmenu': LeftMenu, 124 | UserProfile, 125 | Billing, 126 | 'breadcrumbs': Breadcrumbs 127 | }, 128 | router, 129 | store, 130 | data: { 131 | }, 132 | methods: { 133 | toggleMenu: function(){ 134 | this.$store.commit('toggleSideBar'); 135 | }, 136 | handleScroll: function(){ 137 | 100 < $(window.document).scrollTop() ? $(".scroll-to-top").fadeIn() : $(".scroll-to-top").fadeOut() 138 | }, 139 | scrollToTop: function(event){ 140 | var btn = $(event.currentTarget); 141 | $("html, body").stop().animate({ 142 | scrollTop: $(btn.attr("href")).offset().top 143 | }, 1e3, "easeInOutExpo"); 144 | event.preventDefault(); 145 | }, 146 | redirect: function(url){ 147 | window.location.href = url; 148 | } 149 | }, 150 | computed: { 151 | sideBarOpened() { 152 | return this.$store.state.sideBarOpened; 153 | } 154 | }, 155 | created: function(){ 156 | window.addEventListener('scroll', this.handleScroll); 157 | }, 158 | destroyed: function(){ 159 | window.removeEventListener('scroll', this.handleScroll); 160 | } 161 | }); -------------------------------------------------------------------------------- /app/js/common/commonService.js: -------------------------------------------------------------------------------- 1 | import $ from 'jquery'; 2 | 3 | var commonService = { 4 | startLoader: function(targetButton, waitText){ 5 | if (!targetButton){ 6 | return; 7 | } 8 | var buttonObject = $(targetButton); 9 | var icon = buttonObject.find('i'); 10 | if (icon){ 11 | buttonObject[0].oldIcon = icon; 12 | icon.detach(); 13 | } 14 | buttonObject[0].oldText = buttonObject.text(); 15 | buttonObject.text(waitText); 16 | 17 | buttonObject.prepend(''); 18 | buttonObject.prop('disabled', true); 19 | }, 20 | stopLoader: function(targetButton){ 21 | if (!targetButton){ 22 | return; 23 | } 24 | var buttonObject = $(targetButton); 25 | buttonObject.text(buttonObject[0].oldText); 26 | if (buttonObject[0].oldIcon){ 27 | buttonObject.prepend(buttonObject[0].oldIcon); 28 | } 29 | delete buttonObject[0].oldText; 30 | delete buttonObject[0].oldIcon; 31 | buttonObject.find('.icon-span').remove(); 32 | buttonObject.prop('disabled', false); 33 | }, 34 | redirectToRoute: function(caller, routeName, params){ 35 | var routeObject = { 36 | name: routeName 37 | }; 38 | if (params){ 39 | routeObject.params = params; 40 | } 41 | caller.$router.push(routeObject); 42 | } 43 | }; 44 | export { commonService }; -------------------------------------------------------------------------------- /app/js/common/httpService.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | var httpService = { 4 | init: function(){ 5 | axios.defaults.headers.common = { 6 | 'X-Requested-With': 'XMLHttpRequest', 7 | 'X-CSRFToken': window.csrf_token 8 | }; 9 | }, 10 | formFullUrl: function(rootUrl, entityUrl){ 11 | var url = ''; 12 | if (rootUrl && entityUrl){ 13 | var delimiter = (rootUrl[rootUrl.length - 1] == '/' ? '' : '/'); 14 | url = rootUrl + delimiter + entityUrl; 15 | } 16 | return url; 17 | }, 18 | post: function (url, data) { 19 | return axios.post(url, data); 20 | }, 21 | get: function(url){ 22 | return axios.get(url); 23 | } 24 | }; 25 | httpService.init(); 26 | export { httpService }; -------------------------------------------------------------------------------- /app/js/leftMenu.js: -------------------------------------------------------------------------------- 1 | import { commonService } from '@app/js/common/commonService.js'; 2 | export default { 3 | name: 'LeftMenu', 4 | template: require('./../templates/leftMenu.html'), 5 | data() { 6 | return { 7 | } 8 | }, 9 | methods: { 10 | openUrl: function(routeName, params){ 11 | console.log(routeName); 12 | commonService.redirectToRoute(this, routeName, params); 13 | } 14 | }, 15 | computed: { 16 | sideBarOpened() { 17 | return this.$store.state.sideBarOpened; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/js/styles/stylesAuth.js: -------------------------------------------------------------------------------- 1 | import '@app/scss/auth.scss'; -------------------------------------------------------------------------------- /app/js/styles/stylesDashboard.js: -------------------------------------------------------------------------------- 1 | import '@app/scss/dashboard.scss'; -------------------------------------------------------------------------------- /app/js/userRoutes.js: -------------------------------------------------------------------------------- 1 | import Sample_long_page from './views/Sample_long_page.vue'; 2 | import Simple_elements from './views/UI_Element_library_Simple_elements.vue'; 3 | import Pages from './views/Pages.vue'; 4 | 5 | 6 | var userRoutes = [ 7 | { 8 | path: '/sample', 9 | name: 'Sample_long_page', 10 | component: Sample_long_page, 11 | meta: { 12 | breadcrumb: [{name: 'Sample long page', route: 'Sample_long_page'}] 13 | } 14 | 15 | }, 16 | { 17 | path: '/library/simple', 18 | name: 'Simple_elements', 19 | component: Simple_elements, 20 | meta: { 21 | breadcrumb: [{name: 'UI Element library', route: 'UI_Element_library'}, 22 | {name: 'Simple elements', route: 'Simple_elements'} 23 | ] 24 | } 25 | }, 26 | { 27 | path: '/dashboard/Pages', 28 | name: 'Pages', 29 | component: Pages, 30 | meta: { 31 | breadcrumb: [{name: 'Pages', route: 'Pages'}] 32 | } 33 | 34 | }, 35 | ]; 36 | 37 | export default userRoutes;/*import Sample_long_page from './views/Sample_long_page.vue'; 38 | import Simple_elements from './views/UI_Element_library_Simple_elements.vue'; 39 | import Pages from './views/Pages.vue'; 40 | 41 | 42 | var userRoutes = [ 43 | { 44 | path: '/sample', 45 | name: 'Sample_long_page', 46 | component: Sample_long_page, 47 | meta: { 48 | breadcrumb: [{name: 'Sample long page', route: 'Sample_long_page'}] 49 | } 50 | 51 | }, 52 | { 53 | path: '/library/simple', 54 | name: 'Simple_elements', 55 | component: Simple_elements, 56 | meta: { 57 | breadcrumb: [{name: 'UI Element library', route: 'UI_Element_library'}, 58 | {name: 'Simple elements', route: 'Simple_elements'} 59 | ] 60 | } 61 | }, 62 | { 63 | path: '/dashboard/Pages', 64 | name: 'Pages', 65 | component: Pages, 66 | meta: { 67 | breadcrumb: [{name: 'Pages', route: 'Pages'}] 68 | } 69 | 70 | }, 71 | ]; 72 | 73 | export default userRoutes;import Sample_long_page from './views/Sample_long_page.vue'; 74 | import Simple_elements from './views/UI_Element_library_Simple_elements.vue'; 75 | import Pages from './views/Pages.vue'; 76 | import RegisterPage from './views/Pages_RegisterPage.vue'; 77 | import LoginPage from './views/Pages_LoginPage.vue'; 78 | 79 | 80 | var userRoutes = [ 81 | { 82 | path: '/sample', 83 | name: 'Sample_long_page', 84 | component: Sample_long_page, 85 | meta: { 86 | breadcrumb: [{name: 'Sample long page', route: 'Sample_long_page'}] 87 | } 88 | 89 | }, 90 | { 91 | path: '/library/simple', 92 | name: 'Simple_elements', 93 | component: Simple_elements, 94 | meta: { 95 | breadcrumb: [{name: 'UI Element library', route: 'UI_Element_library'}, 96 | {name: 'Simple elements', route: 'Simple_elements'} 97 | ] 98 | } 99 | }, 100 | { 101 | path: '/dashboard/Pages', 102 | name: 'Pages', 103 | component: Pages, 104 | meta: { 105 | breadcrumb: [{name: 'Pages', route: 'Pages'}] 106 | } 107 | 108 | }, 109 | { 110 | path: '/register', 111 | name: 'RegisterPage', 112 | component: RegisterPage, 113 | meta: { 114 | breadcrumb: [{name: 'Pages', route: 'Pages'}, 115 | {name: 'Register page', route: 'RegisterPage'} 116 | ] 117 | } 118 | }, 119 | { 120 | path: '/login', 121 | name: 'LoginPage', 122 | component: LoginPage, 123 | meta: { 124 | breadcrumb: [{name: 'Pages', route: 'Pages'}, 125 | {name: 'Login page', route: 'LoginPage'} 126 | ] 127 | } 128 | }, 129 | ]; 130 | 131 | export default userRoutes;import Sample_long_page from './views/Sample_long_page.vue'; 132 | import Simple_elements from './views/UI_Element_library_Simple_elements.vue'; 133 | import Pages from './views/Pages.vue'; 134 | import LoginPage from './views/Pages_LoginPage.vue'; 135 | 136 | 137 | var userRoutes = [ 138 | { 139 | path: '/sample', 140 | name: 'Sample_long_page', 141 | component: Sample_long_page, 142 | meta: { 143 | breadcrumb: [{name: 'Sample long page', route: 'Sample_long_page'}] 144 | } 145 | 146 | }, 147 | { 148 | path: '/library/simple', 149 | name: 'Simple_elements', 150 | component: Simple_elements, 151 | meta: { 152 | breadcrumb: [{name: 'UI Element library', route: 'UI_Element_library'}, 153 | {name: 'Simple elements', route: 'Simple_elements'} 154 | ] 155 | } 156 | }, 157 | { 158 | path: '/dashboard/Pages', 159 | name: 'Pages', 160 | component: Pages, 161 | meta: { 162 | breadcrumb: [{name: 'Pages', route: 'Pages'}] 163 | } 164 | 165 | }, 166 | { 167 | path: '/dashboard/LoginPage', 168 | name: 'LoginPage', 169 | component: LoginPage, 170 | meta: { 171 | breadcrumb: [{name: 'Pages', route: 'Pages'}, 172 | {name: 'Login page', route: 'LoginPage'} 173 | ] 174 | } 175 | }, 176 | ]; 177 | 178 | export default userRoutes;*/ -------------------------------------------------------------------------------- /app/js/views/Pages.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | -------------------------------------------------------------------------------- /app/js/views/Sample_long_page.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 75 | 76 | -------------------------------------------------------------------------------- /app/js/views/UI_Element_library_Simple_elements.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 83 | 84 | -------------------------------------------------------------------------------- /app/js/views/billing/billing.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 39 | 40 | -------------------------------------------------------------------------------- /app/js/views/billing/billingHistory.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 66 | 67 | -------------------------------------------------------------------------------- /app/js/views/billing/paymentMethod.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 161 | 162 | -------------------------------------------------------------------------------- /app/js/views/billing/plan.vue: -------------------------------------------------------------------------------- 1 | 149 | 150 | 373 | 374 | -------------------------------------------------------------------------------- /app/js/views/billing/summary.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 75 | 76 | -------------------------------------------------------------------------------- /app/js/views/dashboard/breadcrumbs.vue: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import 'bootstrap'; 4 | 5 | 22 | 23 | 63 | 64 | -------------------------------------------------------------------------------- /app/js/views/dashboard/pageNotFound.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/js/views/dashboard/userProfile.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 71 | 72 | -------------------------------------------------------------------------------- /app/scss/auth.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/bootstrap"; 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | } 7 | 8 | body { 9 | display: -ms-flexbox; 10 | display: flex; 11 | -ms-flex-align: center; 12 | align-items: center; 13 | padding-top: 40px; 14 | padding-bottom: 40px; 15 | background-color: #f5f5f5; 16 | } 17 | 18 | .form-auth { 19 | width: 100%; 20 | max-width: 330px; 21 | padding: 15px; 22 | margin: auto; 23 | } 24 | .form-auth .checkbox { 25 | font-weight: 400; 26 | } 27 | .form-auth .form-control { 28 | position: relative; 29 | box-sizing: border-box; 30 | height: auto; 31 | padding: 10px; 32 | font-size: 16px; 33 | } 34 | .form-auth .form-control:focus { 35 | z-index: 2; 36 | outline: none; 37 | box-shadow: none; 38 | } 39 | 40 | .btn-primary { 41 | background-color: #255a94; 42 | } 43 | .form-auth label { 44 | text-align: left; 45 | display: block; 46 | margin-bottom: 2px; 47 | } 48 | 49 | .form-auth input { 50 | margin-bottom: 15px; 51 | } 52 | 53 | .errors-list { 54 | list-style: none; 55 | text-align: left; 56 | background-color: #f79b9b; 57 | padding: 12px; 58 | border-radius: 3px; 59 | color: #5f1d1d; 60 | border: 1px solid #de8383; 61 | } 62 | 63 | /*** Bootstrap extension ***/ 64 | .btn-primary { 65 | border: none; 66 | } 67 | -------------------------------------------------------------------------------- /app/scss/billing/billing.scss: -------------------------------------------------------------------------------- 1 | .billing-price .plan-block { 2 | padding: 25px 35px; 3 | margin: 20px 0px; 4 | border-radius: 2px; 5 | box-shadow: 0 0 14px 1px rgba(202, 198, 202, 0.69); 6 | } 7 | .billing-price .plan-block.selected { 8 | border: 3px solid #a8c8e8; 9 | } 10 | .billing-price .plan-title { 11 | color: #2061a2; 12 | font-weight: bolder; 13 | } 14 | .billing-price ul.features { 15 | list-style: none; 16 | padding-left: 20px; 17 | } 18 | .billing-price .features li { 19 | padding-left: 1.3em; 20 | } 21 | .billing-price .features li:before { 22 | color: #598dc1; 23 | content: "\F111"; 24 | font-size: 10px; 25 | font-weight: 900; 26 | font-family: 'Font Awesome 5 Free'; 27 | display: inline-block; 28 | padding: 10px 2px; 29 | margin: -5px -24px; 30 | position: absolute; 31 | } 32 | .billing-price .price-info { 33 | font-size: 24px; 34 | } 35 | 36 | -------------------------------------------------------------------------------- /app/scss/billing/billingMenu.scss: -------------------------------------------------------------------------------- 1 | .billing-options-block a.tab-link { 2 | display: inline-block; 3 | padding: 5px 20px; 4 | border-radius: 10px; 5 | background-color: #7fabd8; 6 | color: #091e33; 7 | } 8 | .billing-options-block a.tab-link:hover { 9 | background-color: #62a5ea; 10 | color: #f5f6f7; 11 | text-decoration: none; 12 | } 13 | .billing-options-block .tab-link.active, .billing-options-block .tab-link.active:hover, 14 | .billing-options-block .tab-link.active a:hover { 15 | font-weight: bold; 16 | cursor: default; 17 | color: #091e33; 18 | background-color: #7fabd8; 19 | } 20 | .billing-options-block .navigate-links { 21 | display: inline-block; 22 | float: right; 23 | } 24 | .billing-options-block .title { 25 | display: inline-block; 26 | } 27 | .billing-options-block a.inner-link { 28 | color: #145ba1; 29 | text-decoration: none; 30 | } 31 | .billing-options-block a.inner-link:hover { 32 | color: #1a7cdd; 33 | text-decoration: underline; 34 | } 35 | .billing-options-block a.inner-link-disabled, .billing-options-block a.inner-link-disabled:hover{ 36 | cursor: default; 37 | text-decoration: none; 38 | } 39 | .billing-options-block .info-small-block { 40 | font-size: smaller; 41 | margin-top: 15px; 42 | } -------------------------------------------------------------------------------- /app/scss/billing/paymentMethod.scss: -------------------------------------------------------------------------------- 1 | .StripeElement { 2 | background-color: white; 3 | height: 40px; 4 | padding: 10px 12px; 5 | border-radius: 4px; 6 | border: 1px solid #d0d1d2; 7 | box-shadow: 0 1px 3px 0 #e6ebf1; 8 | -webkit-transition: box-shadow 150ms ease; 9 | transition: box-shadow 150ms ease; 10 | } 11 | 12 | .StripeElement--focus { 13 | box-shadow: 0 1px 3px 0 #cfd7df; 14 | } 15 | 16 | .StripeElement--invalid { 17 | border-color: #fa755a; 18 | } 19 | 20 | .StripeElement--webkit-autofill { 21 | background-color: #fefde5 !important; 22 | } 23 | .select-method-block { 24 | position: relative; 25 | min-height: 60px; 26 | } 27 | .select-method-block button{ 28 | position: absolute; 29 | margin-bottom: 0px; 30 | bottom: 0px; 31 | } -------------------------------------------------------------------------------- /app/scss/dashboard.scss: -------------------------------------------------------------------------------- 1 | @import '~bootstrap/scss/bootstrap'; 2 | 3 | $fa-font-path: '~@fortawesome/fontawesome-free/webfonts'; 4 | @import "~@fortawesome/fontawesome-free/scss/fontawesome"; 5 | @import "~@fortawesome/fontawesome-free/scss/regular"; 6 | @import "~@fortawesome/fontawesome-free/scss/solid"; 7 | @import "~@fortawesome/fontawesome-free/scss/brands"; 8 | html { 9 | position: relative; 10 | min-height: 100%; 11 | } 12 | 13 | body { 14 | font-family: 'Open Sans', sans-serif; 15 | font-size: 14px; 16 | height: 100%; 17 | } 18 | [v-cloak] { 19 | display:none; 20 | } 21 | 22 | #wrapper { 23 | display: -webkit-box; 24 | display: -ms-flexbox; 25 | display: flex; 26 | } 27 | 28 | #wrapper #content-wrapper { 29 | overflow-x: hidden; 30 | width: 100%; 31 | padding-top: 1rem; 32 | padding-bottom: 80px; 33 | } 34 | 35 | body.fixed-nav #content-wrapper { 36 | margin-top: 56px; 37 | padding-left: 90px; 38 | } 39 | 40 | #dashboardApp.fixed-nav.sidebar-toggled #content-wrapper { 41 | padding-left: 0; 42 | } 43 | 44 | @media (min-width: 768px) { 45 | body.fixed-nav #content-wrapper { 46 | padding-left: 225px; 47 | } 48 | #dashboardApp.fixed-nav.sidebar-toggled #content-wrapper { 49 | padding-left: 90px; 50 | } 51 | } 52 | 53 | .scroll-to-top { 54 | position: fixed; 55 | right: 15px; 56 | bottom: 15px; 57 | display: none; 58 | width: 50px; 59 | height: 50px; 60 | text-align: center; 61 | color: #fff; 62 | background: rgba(52, 58, 64, 0.5); 63 | line-height: 46px; 64 | } 65 | 66 | .scroll-to-top:focus, .scroll-to-top:hover { 67 | color: white; 68 | } 69 | 70 | .scroll-to-top:hover { 71 | background: #343a40; 72 | } 73 | 74 | .scroll-to-top i { 75 | font-weight: 800; 76 | } 77 | 78 | .smaller { 79 | font-size: 0.7rem; 80 | } 81 | 82 | .o-hidden { 83 | overflow: hidden !important; 84 | } 85 | 86 | .z-0 { 87 | z-index: 0; 88 | } 89 | 90 | .z-1 { 91 | z-index: 1; 92 | } 93 | 94 | .navbar { 95 | background-color: #2e69a5; 96 | } 97 | 98 | .navbar-nav .form-inline .input-group { 99 | width: 100%; 100 | } 101 | 102 | .nav-item i { 103 | font-size: 16px; 104 | margin-right: 5px; 105 | vertical-align: text-bottom; 106 | } 107 | 108 | .navbar-nav .nav-item.active .nav-link { 109 | color: #fff; 110 | } 111 | 112 | .dropdown a { 113 | font-size: 15px; 114 | } 115 | 116 | .dropdown-toggle::after { 117 | display: block; 118 | position: absolute; 119 | top: 50%; 120 | right: 20px; 121 | } 122 | 123 | .navbar-nav .nav-item.dropdown .dropdown-toggle::after { 124 | width: 1rem; 125 | text-align: center; 126 | float: right; 127 | vertical-align: 0; 128 | border: 0; 129 | font-weight: 900; 130 | font-family: 'Font Awesome 5 Free'; 131 | content: '\f105'; 132 | } 133 | .arrow-submenu { 134 | position: absolute; 135 | right: 5px; 136 | top: 10px; 137 | cursor: pointer; 138 | color: #e9f0f8; 139 | } 140 | .arrow-submenu:hover { 141 | color: #ffffff; 142 | } 143 | a.collapsed .arrow-submenu:after { 144 | font-family: "Font Awesome 5 Free"; 145 | font-weight: 900; 146 | content: "\f105"; 147 | font-style: normal; 148 | } 149 | .arrow-submenu:after { 150 | font-family: "Font Awesome 5 Free"; 151 | font-weight: 900; 152 | content: "\f107"; 153 | color: #ffffff; 154 | font-style: normal; 155 | } 156 | 157 | .navbar-nav .nav-item.dropdown.show .dropdown-toggle::after { 158 | content: '\f107'; 159 | } 160 | 161 | .navbar-nav .nav-item.dropdown.no-arrow .dropdown-toggle::after { 162 | display: none; 163 | } 164 | 165 | .navbar-nav .nav-item .nav-link:focus { 166 | outline: none; 167 | } 168 | 169 | .navbar-nav .nav-item .nav-link .badge { 170 | position: absolute; 171 | margin-left: 0.75rem; 172 | top: 0.3rem; 173 | font-weight: 400; 174 | font-size: 0.5rem; 175 | } 176 | 177 | @media (min-width: 768px) { 178 | .navbar-nav .form-inline .input-group { 179 | width: auto; 180 | } 181 | } 182 | 183 | .sidebar { 184 | width: 90px !important; 185 | background-color: #153352; 186 | min-height: calc(100vh - 56px); 187 | } 188 | 189 | .sidebar .nav-item:last-child { 190 | margin-bottom: 1rem; 191 | } 192 | 193 | .sidebar .nav-item .nav-link { 194 | text-align: center; 195 | padding: 0.75rem 1rem; 196 | width: 90px; 197 | position: relative; 198 | font-size: 13px; 199 | color: #f3f1f1; 200 | } 201 | 202 | .sidebar .nav-item .nav-link span { 203 | font-size: 0.65rem; 204 | display: block; 205 | } 206 | 207 | .sidebar .nav-item .dropdown-menu { 208 | position: absolute !important; 209 | -webkit-transform: none !important; 210 | transform: none !important; 211 | left: calc(90px + 0.5rem) !important; 212 | margin: 0; 213 | } 214 | 215 | .sidebar .nav-item .dropdown-menu.dropup { 216 | bottom: 0; 217 | top: auto !important; 218 | } 219 | 220 | .sidebar .nav-item.dropdown .dropdown-toggle::after { 221 | display: none; 222 | } 223 | 224 | 225 | .sidebar .nav-item .nav-link:active, .sidebar .nav-item .nav-link:focus, .sidebar .nav-item .nav-link:hover { 226 | color: rgba(255, 255, 255, 0.75); 227 | } 228 | 229 | .sidebar.toggled { 230 | width: 0 !important; 231 | overflow: hidden; 232 | } 233 | .sidebar .nav-item { 234 | position: relative; 235 | } 236 | @media (min-width: 768px) { 237 | .sidebar { 238 | width: 225px !important; 239 | } 240 | .sidebar .nav-item .nav-link { 241 | display: block; 242 | width: 100%; 243 | background-color: #3f5b77; 244 | border-right: 1px solid #000; 245 | border-left: 1px solid #727b84; 246 | border-top: 1px solid #727b84; 247 | border-bottom: 1px solid #000; 248 | cursor: pointer; 249 | padding: 10px; 250 | text-align: left; 251 | width: 225px; 252 | } 253 | .sidebar .nav-item .nav-link span { 254 | font-size: 14px; 255 | display: inline; 256 | color: #f3f1f1; 257 | } 258 | .sidebar .nav-item .nav-link span:hover { 259 | color: #ffffff; 260 | } 261 | .sidebar .nav-item .dropdown-menu { 262 | position: static !important; 263 | margin: 0 1rem; 264 | top: 0; 265 | } 266 | .sidebar .nav-item.dropdown .dropdown-toggle::after { 267 | display: block; 268 | } 269 | .sidebar.toggled { 270 | overflow: visible; 271 | width: 90px !important; 272 | } 273 | .sidebar.toggled .nav-item:last-child { 274 | margin-bottom: 1rem; 275 | } 276 | .sidebar.toggled .nav-item .nav-link { 277 | text-align: center; 278 | padding: 0.75rem 1rem; 279 | width: 90px; 280 | } 281 | .sidebar.toggled .nav-item .nav-link span { 282 | font-size: 0.65rem; 283 | display: block; 284 | } 285 | .sidebar.toggled .left-submenu-item { 286 | font-size: 0.65rem; 287 | } 288 | .sidebar.toggled .nav-item .dropdown-menu { 289 | position: absolute !important; 290 | -webkit-transform: none !important; 291 | transform: none !important; 292 | left: calc(90px + 0.5rem) !important; 293 | margin: 0; 294 | } 295 | .sidebar.toggled .nav-item .dropdown-menu.dropup { 296 | bottom: 0; 297 | top: auto !important; 298 | } 299 | .sidebar.toggled .nav-item.dropdown .dropdown-toggle::after { 300 | display: none; 301 | } 302 | } 303 | 304 | .sidebar.fixed-top { 305 | top: 56px; 306 | height: calc(100vh - 56px); 307 | overflow-y: auto; 308 | } 309 | 310 | .card-body-icon { 311 | position: absolute; 312 | z-index: 0; 313 | top: -1.25rem; 314 | right: -1rem; 315 | opacity: 0.4; 316 | font-size: 5rem; 317 | -webkit-transform: rotate(15deg); 318 | transform: rotate(15deg); 319 | } 320 | 321 | @media (min-width: 576px) { 322 | .card-columns { 323 | -webkit-column-count: 1; 324 | column-count: 1; 325 | } 326 | } 327 | 328 | @media (min-width: 768px) { 329 | .card-columns { 330 | -webkit-column-count: 2; 331 | column-count: 2; 332 | } 333 | } 334 | 335 | @media (min-width: 1200px) { 336 | .card-columns { 337 | -webkit-column-count: 2; 338 | column-count: 2; 339 | } 340 | } 341 | .leftmenu-toggle { 342 | position: relative; 343 | } 344 | 345 | .leftmenu-toggle::after { 346 | /* display: inline-block; */ 347 | width: 0; 348 | height: 0; 349 | margin-left: .255em; 350 | vertical-align: .255em; 351 | content: ""; 352 | border-top: .3em solid; 353 | border-right: .3em solid transparent; 354 | border-bottom: 0; 355 | border-left: .3em solid transparent; 356 | right: 11px; 357 | position: absolute; 358 | /* top: calc(100% - 50px); */ 359 | top: 20px; 360 | } 361 | 362 | .left-submenu { 363 | display: block; 364 | } 365 | .left-submenu-item { 366 | display: block; 367 | font-size: 13px; 368 | color: #f3f1f1; 369 | padding: 0.4rem 1rem; 370 | border-top: 1px solid #445566; 371 | border-bottom: 1px solid #131c25; 372 | } 373 | .left-submenu-item:hover { 374 | background-color: #1f4771; 375 | text-decoration: none; 376 | color: #ffffff; 377 | border-bottom: 1px solid #1f4771; 378 | border-top: 1px solid #1f4771; 379 | } 380 | 381 | :root { 382 | --input-padding-x: 0.75rem; 383 | --input-padding-y: 0.75rem; 384 | } 385 | 386 | .card-login { 387 | max-width: 25rem; 388 | } 389 | 390 | .card-register { 391 | max-width: 40rem; 392 | } 393 | .loader { 394 | font-size: 38px; 395 | color: #a9b6c3; 396 | } 397 | .loader-in-button { 398 | margin-right: 5px; 399 | } 400 | .icon-green { 401 | color: #3baf33; 402 | margin-right: 5px; 403 | } 404 | .modal-footer-info { 405 | display: inline-block; 406 | width: 100%; 407 | } 408 | .modal-footer-buttons-block { 409 | display: inline-block; 410 | } 411 | 412 | /** Form ***/ 413 | 414 | .form-label-group { 415 | position: relative; 416 | } 417 | 418 | .form-label-group > input, 419 | .form-label-group > label { 420 | padding: var(--input-padding-y) var(--input-padding-x); 421 | height: auto; 422 | } 423 | 424 | .form-label-group > label { 425 | position: absolute; 426 | top: 0; 427 | left: 0; 428 | display: block; 429 | width: 100%; 430 | margin-bottom: 0; 431 | /* Override default `