From 516bc52c2f95353c1c1653427ffd0627acaff1e4 Mon Sep 17 00:00:00 2001 From: Matt Scott Date: Sat, 18 Feb 2023 11:04:14 -0500 Subject: [PATCH] Revert "Revert "Merge pull request #1371 from AgentTNT/AdminLTE-Upgrade"" This reverts commit e2ad3e200179dd02bd8dfe42913b59bae9bd4263. --- configs/development.py | 11 + package.json | 14 +- powerdnsadmin/assets.py | 106 +- powerdnsadmin/routes/__init__.py | 3 +- powerdnsadmin/routes/admin.py | 27 +- powerdnsadmin/routes/base.py | 2 + powerdnsadmin/routes/index.py | 107 +- powerdnsadmin/static/custom/js/custom.js | 16 +- .../templates/admin_edit_account.html | 359 +-- powerdnsadmin/templates/admin_edit_key.html | 236 +- powerdnsadmin/templates/admin_edit_user.html | 248 +- .../templates/admin_global_search.html | 357 +-- powerdnsadmin/templates/admin_history.html | 258 ++- .../templates/admin_history_table.html | 152 +- .../templates/admin_manage_account.html | 167 +- .../templates/admin_manage_keys.html | 244 +- .../templates/admin_manage_user.html | 386 ++-- powerdnsadmin/templates/admin_pdns_stats.html | 209 +- .../admin_setting_authentication.html | 180 +- .../templates/admin_setting_basic.html | 183 +- .../templates/admin_setting_pdns.html | 183 +- .../templates/admin_setting_records.html | 150 +- powerdnsadmin/templates/base.html | 362 +-- powerdnsadmin/templates/dashboard.html | 500 ++-- powerdnsadmin/templates/dashboard_domain.html | 78 +- powerdnsadmin/templates/domain.html | 396 ++-- powerdnsadmin/templates/domain_add.html | 428 ++-- powerdnsadmin/templates/domain_changelog.html | 128 +- powerdnsadmin/templates/domain_remove.html | 210 +- powerdnsadmin/templates/domain_setting.html | 209 +- powerdnsadmin/templates/errors/400.html | 47 +- powerdnsadmin/templates/errors/403.html | 47 +- powerdnsadmin/templates/errors/404.html | 47 +- powerdnsadmin/templates/errors/500.html | 47 +- powerdnsadmin/templates/errors/SAML.html | 47 +- powerdnsadmin/templates/login.html | 313 +-- powerdnsadmin/templates/register.html | 280 ++- powerdnsadmin/templates/register_otp.html | 2 +- powerdnsadmin/templates/template.html | 204 +- powerdnsadmin/templates/template_add.html | 193 +- powerdnsadmin/templates/template_edit.html | 113 +- powerdnsadmin/templates/user_profile.html | 304 +-- requirements.txt | 39 +- yarn.lock | 2053 ++++++++++------- 44 files changed, 5349 insertions(+), 4296 deletions(-) diff --git a/configs/development.py b/configs/development.py index d4bd24f..b848d0c 100644 --- a/configs/development.py +++ b/configs/development.py @@ -15,6 +15,17 @@ SQLA_DB_HOST = '127.0.0.1' SQLA_DB_NAME = 'pda' SQLALCHEMY_TRACK_MODIFICATIONS = True +#CAPTCHA Config +CAPTCHA_ENABLE = True +CAPTCHA_LENGTH = 6 +CAPTCHA_WIDTH = 160 +CAPTCHA_HEIGHT = 60 +CAPTCHA_SESSION_KEY = 'captcha_image' + +#Server side sessions tracking +#Set to TRUE for CAPTCHA, or enable another stateful session tracking system +FILESYSTEM_SESSIONS_ENABLED = True + ### DATABASE - MySQL #SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( # urllib.parse.quote_plus(SQLA_DB_USER), diff --git a/package.json b/package.json index 76982c8..f6724c3 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,17 @@ { "dependencies": { - "admin-lte": "2.4.9", - "bootstrap": "^3.4.1", - "bootstrap-datepicker": "^1.8.0", + "@fortawesome/fontawesome-free": "6.3.0", + "admin-lte": "3.2.0", + "bootstrap": "4.6.2", + "bootstrap-datepicker": "^1.9.0", "bootstrap-validator": "^0.11.9", - "datatables.net-plugins": "^1.10.19", + "datatables.net-plugins": "^1.13.1", "icheck": "^1.0.2", "jquery-slimscroll": "^1.3.8", - "jquery-ui-dist": "^1.12.1", + "jquery-sparkline": "^2.4.0", + "jquery-ui-dist": "^1.13.2", "jquery.quicksearch": "^2.4.0", - "jtimeout": "^3.1.0", + "jtimeout": "^3.2.0", "multiselect": "^0.9.12" } } diff --git a/powerdnsadmin/assets.py b/powerdnsadmin/assets.py index 233e23a..7ad973f 100644 --- a/powerdnsadmin/assets.py +++ b/powerdnsadmin/assets.py @@ -2,67 +2,65 @@ from flask_assets import Bundle, Environment, Filter class ConcatFilter(Filter): - """ - Filter that merges files, placing a semicolon between them. + """ + Filter that merges files, placing a semicolon between them. - Fixes issues caused by missing semicolons at end of JS assets, for example - with last statement of jquery.pjax.js. - """ - def concat(self, out, hunks, **kw): - out.write(';'.join([h.data() for h, info in hunks])) + Fixes issues caused by missing semicolons at end of JS assets, for example + with last statement of jquery.pjax.js. + """ + def concat(self, out, hunks, **kw): + out.write(';'.join([h.data() for h, info in hunks])) +css_login = Bundle( + 'node_modules/@fortawesome/fontawesome-free/css/all.min.css', + 'node_modules/icheck/skins/square/blue.css', + 'node_modules/admin-lte/dist/css/adminlte.css', + filters=('cssmin', 'cssrewrite'), + output='generated/login.css') -css_login = Bundle('node_modules/bootstrap/dist/css/bootstrap.css', - 'node_modules/font-awesome/css/font-awesome.css', - 'node_modules/ionicons/dist/css/ionicons.css', - 'node_modules/icheck/skins/square/blue.css', - 'node_modules/admin-lte/dist/css/AdminLTE.css', - filters=('cssmin', 'cssrewrite'), - output='generated/login.css') +js_login = Bundle( + 'node_modules/jquery/dist/jquery.js', + 'node_modules/bootstrap/dist/js/bootstrap.js', + 'node_modules/icheck/icheck.js', + 'custom/js/custom.js', + filters=(ConcatFilter, 'rjsmin'), + output='generated/login.js') -js_login = Bundle('node_modules/jquery/dist/jquery.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', - 'node_modules/icheck/icheck.js', - 'custom/js/custom.js', - filters=(ConcatFilter, 'rjsmin'), - output='generated/login.js') - -js_validation = Bundle('node_modules/bootstrap-validator/dist/validator.js', - output='generated/validation.js') +js_validation = Bundle( + 'node_modules/bootstrap-validator/dist/validator.js', + output='generated/validation.js') css_main = Bundle( - 'node_modules/bootstrap/dist/css/bootstrap.css', - 'node_modules/font-awesome/css/font-awesome.css', - 'node_modules/ionicons/dist/css/ionicons.css', - 'node_modules/datatables.net-bs/css/dataTables.bootstrap.css', - 'node_modules/icheck/skins/square/blue.css', - 'node_modules/multiselect/css/multi-select.css', - 'node_modules/admin-lte/dist/css/AdminLTE.css', - 'node_modules/admin-lte/dist/css/skins/_all-skins.css', - 'custom/css/custom.css', - 'node_modules/bootstrap-datepicker/dist/css/bootstrap-datepicker.css', - filters=('cssmin', 'cssrewrite'), - output='generated/main.css') + 'node_modules/@fortawesome/fontawesome-free/css/all.min.css', + 'node_modules/datatables.net-bs4/css/dataTables.bootstrap4.min.css', + 'node_modules/icheck/skins/square/blue.css', + 'node_modules/multiselect/css/multi-select.css', + 'node_modules/admin-lte/dist/css/adminlte.css', + 'custom/css/custom.css', + 'node_modules/bootstrap-datepicker/dist/css/bootstrap-datepicker.css', + filters=('cssmin', 'cssrewrite'), + output='generated/main.css') -js_main = Bundle('node_modules/jquery/dist/jquery.js', - 'node_modules/jquery-ui-dist/jquery-ui.js', - 'node_modules/bootstrap/dist/js/bootstrap.js', - 'node_modules/datatables.net/js/jquery.dataTables.js', - 'node_modules/datatables.net-bs/js/dataTables.bootstrap.js', - 'node_modules/jquery-sparkline/jquery.sparkline.js', - 'node_modules/jquery-slimscroll/jquery.slimscroll.js', - 'node_modules/icheck/icheck.js', - 'node_modules/fastclick/lib/fastclick.js', - 'node_modules/moment/moment.js', - 'node_modules/admin-lte/dist/js/adminlte.js', - 'node_modules/multiselect/js/jquery.multi-select.js', - 'node_modules/datatables.net-plugins/sorting/natural.js', - 'node_modules/jtimeout/src/jTimeout.js', - 'node_modules/jquery.quicksearch/src/jquery.quicksearch.js', - 'custom/js/custom.js', - 'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js', - filters=(ConcatFilter, 'rjsmin'), - output='generated/main.js') +js_main = Bundle( + 'node_modules/jquery/dist/jquery.js', + 'node_modules/jquery-ui-dist/jquery-ui.js', + 'node_modules/bootstrap/dist/js/bootstrap.bundle.js', + 'node_modules/datatables.net/js/jquery.dataTables.js', + 'node_modules/datatables.net-bs4/js/dataTables.bootstrap4.js', + 'node_modules/jquery-sparkline/jquery.sparkline.js', + 'node_modules/jquery-slimscroll/jquery.slimscroll.js', + 'node_modules/icheck/icheck.js', + 'node_modules/fastclick/lib/fastclick.js', + 'node_modules/moment/moment.js', + 'node_modules/admin-lte/dist/js/adminlte.js', + 'node_modules/multiselect/js/jquery.multi-select.js', + 'node_modules/datatables.net-plugins/sorting/natural.js', + 'node_modules/jtimeout/src/jTimeout.js', + 'node_modules/jquery.quicksearch/src/jquery.quicksearch.js', + 'custom/js/custom.js', + 'node_modules/bootstrap-datepicker/dist/js/bootstrap-datepicker.js', + filters=(ConcatFilter, 'rjsmin'), + output='generated/main.js') assets = Environment() assets.register('js_login', js_login) diff --git a/powerdnsadmin/routes/__init__.py b/powerdnsadmin/routes/__init__.py index 7d8aa9a..598b17a 100644 --- a/powerdnsadmin/routes/__init__.py +++ b/powerdnsadmin/routes/__init__.py @@ -1,5 +1,5 @@ from .base import ( - csrf, login_manager, handle_bad_request, handle_unauthorized_access, + captcha, csrf, login_manager, handle_bad_request, handle_unauthorized_access, handle_access_forbidden, handle_page_not_found, handle_internal_server_error ) @@ -14,6 +14,7 @@ from .api import api_bp, apilist_bp def init_app(app): login_manager.init_app(app) csrf.init_app(app) + captcha.init_app(app) app.register_blueprint(index_bp) app.register_blueprint(user_bp) diff --git a/powerdnsadmin/routes/admin.py b/powerdnsadmin/routes/admin.py index e419fa8..98f4ef9 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -795,7 +795,7 @@ class DetailedHistory(): if 'domain_type' in detail_dict and 'account_id' in detail_dict: # this is a domain creation self.detailed_msg = render_template_string(""" - +
Domain type:{{ domaintype }}
Domain Type:{{ domaintype }}
Account:{{ account }}
""", @@ -804,22 +804,23 @@ class DetailedHistory(): elif 'authenticator' in detail_dict: # this is a user authentication self.detailed_msg = render_template_string(""" - - - - - - +
-

User {{ username }} authentication {{ auth_result }}

-
- - + + - - + + + + + + + + + +
Authenticator Type:{{ authenticator }}Username:{{ username }}
IP Address{{ ip_address }}Authentication Result:{{ auth_result }}
Authenticator Type:{{ authenticator }}
IP Address:{{ ip_address }}
diff --git a/powerdnsadmin/routes/base.py b/powerdnsadmin/routes/base.py index 16ed00a..7af342c 100644 --- a/powerdnsadmin/routes/base.py +++ b/powerdnsadmin/routes/base.py @@ -3,10 +3,12 @@ import base64 from flask import render_template, url_for, redirect, session, request, current_app from flask_login import LoginManager from flask_seasurf import SeaSurf +from flask_session_captcha import FlaskSessionCaptcha from ..models.user import User +captcha = FlaskSessionCaptcha() csrf = SeaSurf() login_manager = LoginManager() diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index 117fe14..14ad275 100644 --- a/powerdnsadmin/routes/index.py +++ b/powerdnsadmin/routes/index.py @@ -10,7 +10,7 @@ from yaml import Loader, load from flask import Blueprint, render_template, make_response, url_for, current_app, g, session, request, redirect, abort from flask_login import login_user, logout_user, login_required, current_user -from .base import csrf, login_manager +from .base import captcha, csrf, login_manager from ..lib import utils from ..decorators import dyndns_login_required from ..models.base import db @@ -400,7 +400,7 @@ def login(): desc_prop = Setting().get('oidc_oauth_account_description_property') account_to_add = [] - #If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP). + #If the name_property and desc_property exist in me (A variable that contains all the userinfo from the IdP). if name_prop in me and desc_prop in me: accounts_name_prop = [me[name_prop]] if type(me[name_prop]) is not list else me[name_prop] accounts_desc_prop = [me[desc_prop]] if type(me[desc_prop]) is not list else me[desc_prop] @@ -415,7 +415,7 @@ def login(): account_to_add.append(account) user_accounts = user.get_accounts() - # Add accounts + # Add accounts for account in account_to_add: if account not in user_accounts: account.add_user(user) @@ -651,50 +651,73 @@ def logout(): @index_bp.route('/register', methods=['GET', 'POST']) def register(): - if Setting().get('signup_enabled'): - if request.method == 'GET': - return render_template('register.html') - elif request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - firstname = request.form.get('firstname', '').strip() - lastname = request.form.get('lastname', '').strip() - email = request.form.get('email', '').strip() - rpassword = request.form.get('rpassword', '') + CAPTCHA_ENABLE = current_app.config.get('CAPTCHA_ENABLE') + if Setting().get('signup_enabled'): + if current_user.is_authenticated: + return redirect(url_for('index.index')) + if request.method == 'GET': + return render_template('register.html', captcha_enable=CAPTCHA_ENABLE) + elif request.method == 'POST': + username = request.form.get('username', '').strip() + password = request.form.get('password', '') + firstname = request.form.get('firstname', '').strip() + lastname = request.form.get('lastname', '').strip() + email = request.form.get('email', '').strip() + rpassword = request.form.get('rpassword', '') - if not username or not password or not email: - return render_template( - 'register.html', error='Please input required information') + is_valid_email = re.compile(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$') - if password != rpassword: - return render_template( - 'register.html', - error="Password confirmation does not match") + error_messages = {} + if not firstname: + error_messages['firstname'] = 'First Name is required' + if not lastname: + error_messages['lastname'] = 'Last Name is required' + if not username: + error_messages['username'] = 'Username is required' + if not password: + error_messages['password'] = 'Password is required' + if not rpassword: + error_messages['rpassword'] = 'Password confirmation is required' + if not email: + error_messages['email'] = 'Email is required' + if not is_valid_email.match(email): + error_messages['email'] = 'Invalid email address' + if password != rpassword: + error_messages['password'] = 'Password confirmation does not match' + error_messages['rpassword'] = 'Password confirmation does not match' - user = User(username=username, - plain_text_password=password, - firstname=firstname, - lastname=lastname, - email=email) + if not captcha.validate(): + return render_template( + 'register.html', error='Invalid CAPTCHA answer', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE) - try: - result = user.create_local_user() - if result and result['status']: - if Setting().get('verify_user_email'): - send_account_verification(email) - if Setting().get('otp_force') and Setting().get('otp_field_enabled'): - user.update_profile(enable_otp=True) - prepare_welcome_user(user.id) - return redirect(url_for('index.welcome')) - else: - return redirect(url_for('index.login')) - else: - return render_template('register.html', - error=result['msg']) - except Exception as e: - return render_template('register.html', error=e) + if error_messages: + return render_template('register.html', error_messages=error_messages, captcha_enable=CAPTCHA_ENABLE) + + user = User(username=username, + plain_text_password=password, + firstname=firstname, + lastname=lastname, + email=email + ) + + try: + result = user.create_local_user() + if result and result['status']: + if Setting().get('verify_user_email'): + send_account_verification(email) + if Setting().get('otp_force') and Setting().get('otp_field_enabled'): + user.update_profile(enable_otp=True) + prepare_welcome_user(user.id) + return redirect(url_for('index.welcome')) + else: + return redirect(url_for('index.login')) + else: + return render_template('register.html', + error=result['msg'], captcha_enable=CAPTCHA_ENABLE) + except Exception as e: + return render_template('register.html', error=e, captcha_enable=CAPTCHA_ENABLE) else: - return render_template('errors/404.html'), 404 + return render_template('errors/404.html'), 404 # Show welcome page on first login if otp_force is enabled diff --git a/powerdnsadmin/static/custom/js/custom.js b/powerdnsadmin/static/custom/js/custom.js index e4b72b1..0f096bf 100644 --- a/powerdnsadmin/static/custom/js/custom.js +++ b/powerdnsadmin/static/custom/js/custom.js @@ -287,4 +287,18 @@ function copy_otp_secret_to_clipboard() { navigator.clipboard.writeText(copyBox.value); $("#copy_tooltip").css("visibility", "visible"); setTimeout(function(){ $("#copy_tooltip").css("visibility", "collapse"); }, 2000); - } \ No newline at end of file + } + +// Side menu nav bar active selection +/** add active class and stay opened when selected */ +var url = window.location; + +// for sidebar menu entirely but not cover treeview +$('ul.nav-sidebar a').filter(function() { + return this.href == url; +}).addClass('active'); + +// for treeview +$('ul.nav-treeview a').filter(function() { + return this.href == url; +}).parentsUntil(".nav-sidebar > .nav-treeview").addClass('menu-open').prev('a').addClass('active'); diff --git a/powerdnsadmin/templates/admin_edit_account.html b/powerdnsadmin/templates/admin_edit_account.html index ee8d1be..046866c 100644 --- a/powerdnsadmin/templates/admin_edit_account.html +++ b/powerdnsadmin/templates/admin_edit_account.html @@ -1,192 +1,211 @@ {% extends "base.html" %} + {% set active_page = "admin_accounts" %} -{% block title %}Edit Account - {{ SITE_NAME }}{% endblock %} + +{% block title %} + + Edit Account - {{ SITE_NAME }} + +{% endblock %} {% block dashboard_stat %} - -
-

- Account - {% if create %}New account{% else %}{{ account.name }}{% endif %} -

- -
+
+
+
+
+

+ {% if create %}Add Account{% else %}Edit Account{% endif %} + {% if create %}Account{% else %}{{ account.name }}{% endif %} +

+
+
+ +
+
+
+
{% endblock %} {% block content %}
+
-
-
-
-

{% if create %}Add{% else %}Edit{% endif %} account

+
+
+
+

{% if create %}Add{% else %}Edit{% endif %} Account

+
+
+ + +
+ {% if error %} +
+ +

Error!

+ {{ error }}
- - - - - -
- {% if error %} -
- -

Error!

- {{ error }} -
- {{ error }} - {% endif %} -
- - - - {% if invalid_accountname %} - Cannot be blank and must only contain alphanumeric - characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens or underscores{% endif %}. - {% elif duplicate_accountname %} - Account name already in use. - {% endif %} -
-
- - - -
-
- - - -
-
- - - -
-
-
-

Access Control

-
-
-

Users on the right have access to manage records in all domains - associated with the account.

-

Click on users to move between columns.

-
- -
-
-
-

Domains on the right are associated with the account. Red marked domain names are already associated with other accounts. - Moving already associated domains to this account will overwrite the previous associated account. -

-

Hover over the red domain names to show the associated account. Click on domains to move between columns.

-
- -
-
- - + {{ error }} + {% endif %} +
+ + + + {% if invalid_accountname %} + Cannot be blank and must only contain alphanumeric + characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens or underscores{% endif %}. + + {% elif duplicate_accountname %} + Account name already in use. + {% endif %} +
+
+ + + +
+
+ + + +
+
+ + + +
-
-
-
-
-

Help with creating a new account

-
-
-

- An account allows grouping of domains belonging to a particular entity, such as a customer or - department.
- A domain can be assigned to an account upon domain creation or through the domain administration - page. -

-

Fill in all the fields to the in the form to the left.

-

- Name is an account identifier. It will be lowercased and can contain alphanumeric - characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens and underscores (no space or other special character is allowed) - {% else %} (no extra character is allowed){% endif %}.
- Description is a user friendly name for this account.
- Contact person is the name of a contact person at the account.
- Mail Address is an e-mail address for the contact person. -

-
+
+

Access Control

+
+

Users on the right have access to manage records in all domains + associated with the account. +

+

Click on users to move between columns.

+
+ +
+
+
+

Domains on the right are associated with the account. Red marked domain names are already associated with other accounts. + Moving already associated domains to this account will overwrite the previous associated account. +

+

Hover over the red domain names to show the associated account. Click on domains to move between columns.

+
+ +
+
+ +
+
+
+
+
+

Help with creating a new account

+
+
+

+ An account allows grouping of domains belonging to a particular entity, such as a customer or + department. +
+ A domain can be assigned to an account upon domain creation or through the domain administration + page. +

+

Fill in all the fields to the in the form to the left.

+

+ Name is an account identifier. It will be lowercased and can contain alphanumeric + characters{% if SETTING.get('account_name_extra_chars') %}, dots, hyphens and underscores (no space or other special character is allowed) + {% else %} (no extra character is allowed){% endif %}.
+ Description is a user friendly name for this account.
+ Contact person is the name of a contact person at the account.
+ Mail Address is an e-mail address for the contact person. +

+
+
+
+
{% endblock %} {% block extrascripts %} {% endblock %} diff --git a/powerdnsadmin/templates/admin_edit_key.html b/powerdnsadmin/templates/admin_edit_key.html index 6a94340..23f58d3 100644 --- a/powerdnsadmin/templates/admin_edit_key.html +++ b/powerdnsadmin/templates/admin_edit_key.html @@ -1,80 +1,92 @@ {% extends "base.html" %} + {% set active_page = "admin_keys" %} + {% if (key is not none and key.role.name != "User") %}{% set hide_opts = True %}{%else %}{% set hide_opts = False %}{% endif %} + {% block title %} -Edit Key - {{ SITE_NAME }} + + Edit Key - {{ SITE_NAME }} + {% endblock %} + {% block dashboard_stat %} - -
-

- Key - {% if create %}New key{% else %}{{ key.id }}{% endif %} -

- -
+
+
+
+
+

+ API Keys + {% if create %}Add API Key{% else %}Edit API Key - {{ key.id }}{% endif %} +

+
+
+ +
+
+
+
{% endblock %} {% block content %}
-
-
-
-
-

{% if create %}Add{% else %}Edit{% endif %} key

-
- - -
- - -
-
- - -
-
- - -
-
- - - -
{% endblock %} + {% block extrascripts %} - - + {% endblock %} diff --git a/powerdnsadmin/templates/admin_history.html b/powerdnsadmin/templates/admin_history.html index aa16c4a..f2a8619 100644 --- a/powerdnsadmin/templates/admin_history.html +++ b/powerdnsadmin/templates/admin_history.html @@ -1,81 +1,108 @@ {% extends "base.html" %} + {% set active_page = "admin_history" %} + {% block title %} -History - {{ SITE_NAME }} -{% endblock %} {% block dashboard_stat %} - -
-

- History Recent events -

- -
+ + History - {{ SITE_NAME }} + +{% endblock %} + +{% block dashboard_stat %} +
+
+
+
+

+ History + Recent Events +

+
+
+ +
+
+
+
{% endblock %} + {% block content %} {% import 'applied_change_macro.html' as applied_change_macro %} - - -
+
-
-
-
-

History Management

+
+
+
+

History Management

+ {% if current_user.role.name != 'User' %} + + {% endif %} +
+
+
+ +
+
+
+
+ + + + +
+
-
- -
- -
- - - - -
-
{% endblock %} {% block extrascripts %} @@ -471,49 +485,45 @@ {% endblock %} {% block modals %} - -