diff --git a/.gitignore b/.gitignore index 539325f..91bfea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +flask_session + # gedit *~ 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/configs/docker_config.py b/configs/docker_config.py index 1099a4a..42fffd5 100644 --- a/configs/docker_config.py +++ b/configs/docker_config.py @@ -4,6 +4,7 @@ PORT = 80 SQLALCHEMY_DATABASE_URI = 'sqlite:////data/powerdns-admin.db' SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_HTTPONLY = True +FILESYSTEM_SESSIONS_ENABLED = True legal_envvars = ( 'SECRET_KEY', diff --git a/docker-compose-test.yml b/docker-compose-test.yml index 7dcf4a0..77c8dba 100644 --- a/docker-compose-test.yml +++ b/docker-compose-test.yml @@ -1,11 +1,11 @@ -version: "2.1" +version: "3.8" services: powerdns-admin: + image: powerdns-admin-test build: context: . dockerfile: docker-test/Dockerfile - image: powerdns-admin-test container_name: powerdns-admin-test ports: - "9191:80" @@ -17,10 +17,10 @@ services: - pdns-server pdns-server: + image: pdns-server-test build: context: . dockerfile: docker-test/Dockerfile.pdns - image: pdns-server-test ports: - "5053:53" - "5053:53/udp" diff --git a/docker-test/Dockerfile b/docker-test/Dockerfile index 577e120..17f934f 100644 --- a/docker-test/Dockerfile +++ b/docker-test/Dockerfile @@ -1,15 +1,35 @@ -FROM debian:stretch-slim +FROM debian:bullseye-slim LABEL maintainer="k@ndk.name" ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 LANGUAGE=en_US.UTF-8 RUN apt-get update -y \ - && apt-get install -y --no-install-recommends apt-transport-https locales locales-all python3-pip python3-setuptools python3-dev curl libsasl2-dev libldap2-dev libssl-dev libxml2-dev libxslt1-dev libxmlsec1-dev libffi-dev build-essential libmariadb-dev-compat \ - && curl -sL https://deb.nodesource.com/setup_10.x | bash - \ + && apt-get install -y --no-install-recommends \ + apt-transport-https \ + curl \ + build-essential \ + libffi-dev \ + libldap2-dev \ + libmariadb-dev-compat \ + libsasl2-dev \ + libssl-dev \ + libxml2-dev \ + libxmlsec1-dev \ + libxmlsec1-openssl \ + libxslt1-dev \ + locales \ + locales-all \ + pkg-config \ + python3-dev \ + python3-pip \ + python3-setuptools \ + && curl -sL https://deb.nodesource.com/setup_lts.x | bash - \ && curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && echo "deb https://dl.yarnpkg.com/debian/ stable main" > /etc/apt/sources.list.d/yarn.list \ && apt-get update -y \ - && apt-get install -y nodejs yarn \ + && apt-get install -y --no-install-recommends \ + nodejs \ + yarn \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* @@ -21,8 +41,6 @@ RUN pip3 install --upgrade pip RUN pip3 install -r requirements.txt COPY . /app -COPY ./docker/entrypoint.sh /usr/local/bin/ -RUN chmod +x /usr/local/bin/entrypoint.sh ENV FLASK_APP=powerdnsadmin/__init__.py RUN yarn install --pure-lockfile --production \ @@ -31,4 +49,4 @@ RUN yarn install --pure-lockfile --production \ COPY ./docker-test/wait-for-pdns.sh /opt RUN chmod u+x /opt/wait-for-pdns.sh -CMD ["/opt/wait-for-pdns.sh", "/usr/local/bin/pytest","--capture=no","-vv"] +CMD ["/opt/wait-for-pdns.sh", "/usr/local/bin/pytest", "-W", "ignore::DeprecationWarning", "--capture=no", "-vv"] diff --git a/docker-test/start.sh b/docker-test/start.sh index 9a66017..efd1c0e 100644 --- a/docker-test/start.sh +++ b/docker-test/start.sh @@ -10,9 +10,9 @@ fi # Import schema structure if [ -e "/data/pdns.sql" ]; then - rm /data/pdns.db + rm -f /data/pdns.db cat /data/pdns.sql | sqlite3 /data/pdns.db - rm /data/pdns.sql + rm -f /data/pdns.sql echo "Imported schema structure" fi diff --git a/docker/Dockerfile b/docker/Dockerfile index e9905d6..4618239 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,6 +7,7 @@ ARG BUILD_DEPENDENCIES="build-base \ openldap-dev \ python3-dev \ xmlsec-dev \ + npm \ yarn \ cargo" diff --git a/migrations/versions/787bdba9e147_init_db.py b/migrations/versions/787bdba9e147_init_db.py index aa781de..c4c7aa2 100644 --- a/migrations/versions/787bdba9e147_init_db.py +++ b/migrations/versions/787bdba9e147_init_db.py @@ -56,9 +56,9 @@ def seed_data(): op.bulk_insert(template_table, [ - {id: 1, 'name': 'basic_template_1', 'description': 'Basic Template #1'}, - {id: 2, 'name': 'basic_template_2', 'description': 'Basic Template #2'}, - {id: 3, 'name': 'basic_template_3', 'description': 'Basic Template #3'} + {'id': 1, 'name': 'basic_template_1', 'description': 'Basic Template #1'}, + {'id': 2, 'name': 'basic_template_2', 'description': 'Basic Template #2'}, + {'id': 3, 'name': 'basic_template_3', 'description': 'Basic Template #3'} ] ) diff --git a/migrations/versions/b24bf17725d2_add_unique_index_to_settings_table_keys.py b/migrations/versions/b24bf17725d2_add_unique_index_to_settings_table_keys.py new file mode 100644 index 0000000..63ae3eb --- /dev/null +++ b/migrations/versions/b24bf17725d2_add_unique_index_to_settings_table_keys.py @@ -0,0 +1,24 @@ +"""Add unique index to settings table keys + +Revision ID: b24bf17725d2 +Revises: f41520e41cee +Create Date: 2023-02-18 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b24bf17725d2' +down_revision = 'f41520e41cee' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_index(op.f('ix_setting_name'), 'setting', ['name'], unique=True) + + +def downgrade(): + op.drop_index(op.f('ix_setting_name'), table_name='setting') 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..52e8d26 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.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.css', + 'node_modules/datatables.net-bs4/css/dataTables.bootstrap4.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/decorators.py b/powerdnsadmin/decorators.py index 6382141..50e4a3f 100644 --- a/powerdnsadmin/decorators.py +++ b/powerdnsadmin/decorators.py @@ -388,7 +388,7 @@ def apikey_can_configure_dnssec(http_methods=[]): def allowed_record_types(f): @wraps(f) def decorated_function(*args, **kwargs): - if request.method == 'GET': + if request.method in ['GET', 'DELETE', 'PUT']: return f(*args, **kwargs) if g.apikey.role.name in ['Administrator', 'Operator']: diff --git a/powerdnsadmin/default_config.py b/powerdnsadmin/default_config.py index 81d0e32..8513915 100644 --- a/powerdnsadmin/default_config.py +++ b/powerdnsadmin/default_config.py @@ -8,10 +8,17 @@ SECRET_KEY = 'e951e5a1f4b94151b360f47edf596dd2' BIND_ADDRESS = '0.0.0.0' PORT = 9191 HSTS_ENABLED = False -FILESYSTEM_SESSIONS_ENABLED = False +FILESYSTEM_SESSIONS_ENABLED = True SESSION_COOKIE_SAMESITE = 'Lax' CSRF_COOKIE_HTTPONLY = True +#CAPTCHA Config +CAPTCHA_ENABLE = True +CAPTCHA_LENGTH = 6 +CAPTCHA_WIDTH = 160 +CAPTCHA_HEIGHT = 60 +CAPTCHA_SESSION_KEY = 'captcha_image' + ### DATABASE CONFIG SQLA_DB_USER = 'pda' SQLA_DB_PASSWORD = 'changeme' @@ -20,15 +27,15 @@ SQLA_DB_NAME = 'pda' SQLALCHEMY_TRACK_MODIFICATIONS = True ### DATABASE - MySQL -SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( - urllib.parse.quote_plus(SQLA_DB_USER), - urllib.parse.quote_plus(SQLA_DB_PASSWORD), - SQLA_DB_HOST, - SQLA_DB_NAME -) +# SQLALCHEMY_DATABASE_URI = 'mysql://{}:{}@{}/{}'.format( +# urllib.parse.quote_plus(SQLA_DB_USER), +# urllib.parse.quote_plus(SQLA_DB_PASSWORD), +# SQLA_DB_HOST, +# SQLA_DB_NAME +# ) ### DATABASE - SQLite -# SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'pdns.db') # SAML Authnetication SAML_ENABLED = False diff --git a/powerdnsadmin/models/domain.py b/powerdnsadmin/models/domain.py index ab154c1..500ad74 100644 --- a/powerdnsadmin/models/domain.py +++ b/powerdnsadmin/models/domain.py @@ -2,6 +2,7 @@ import json import re import traceback from flask import current_app +from flask_login import current_user from urllib.parse import urljoin from distutils.util import strtobool @@ -548,11 +549,12 @@ class Domain(db.Model): domain.apikeys[:] = [] # Remove history for domain - domain_history = History.query.filter( - History.domain_id == domain.id - ) - if domain_history: - domain_history.delete() + if not Setting().get('preserve_history'): + domain_history = History.query.filter( + History.domain_id == domain.id + ) + if domain_history: + domain_history.delete() # then remove domain Domain.query.filter(Domain.name == domain_name).delete() @@ -851,6 +853,7 @@ class Domain(db.Model): headers = {'X-API-Key': self.PDNS_API_KEY, 'Content-Type': 'application/json'} + account_name_old = Account().get_name_by_id(domain.account_id) account_name = Account().get_name_by_id(account_id) post_data = {"account": account_name} @@ -874,6 +877,13 @@ class Domain(db.Model): self.update() msg_str = 'Account changed for domain {0} successfully' current_app.logger.info(msg_str.format(domain_name)) + history = History(msg='Update domain {0} associate account {1}'.format(domain.name, 'none' if account_name == '' else account_name), + detail = json.dumps({ + 'assoc_account': 'None' if account_name == '' else account_name, + 'dissoc_account': 'None' if account_name_old == '' else account_name_old + }), + created_by=current_user.username) + history.add() return {'status': 'ok', 'msg': 'account changed successfully'} except Exception as e: diff --git a/powerdnsadmin/models/record.py b/powerdnsadmin/models/record.py index b6c3f54..ae70a9e 100644 --- a/powerdnsadmin/models/record.py +++ b/powerdnsadmin/models/record.py @@ -422,6 +422,25 @@ class Record(object): ] d = Domain() + for r in del_rrsets: + for record in r['records']: + # Format the reverse record name + # It is the reverse of forward record's content. + reverse_host_address = dns.reversename.from_address( + record['content']).to_text() + + # Create the reverse domain name in PDNS + domain_reverse_name = d.get_reverse_domain_name( + reverse_host_address) + d.create_reverse_domain(domain_name, + domain_reverse_name) + + # Delete the reverse zone + self.name = reverse_host_address + self.type = 'PTR' + self.data = record['content'] + self.delete(domain_reverse_name) + for r in new_rrsets: for record in r['records']: # Format the reverse record name @@ -455,25 +474,6 @@ class Record(object): # Format the rrset rrset = {"rrsets": rrset_data} self.add(domain_reverse_name, rrset) - - for r in del_rrsets: - for record in r['records']: - # Format the reverse record name - # It is the reverse of forward record's content. - reverse_host_address = dns.reversename.from_address( - record['content']).to_text() - - # Create the reverse domain name in PDNS - domain_reverse_name = d.get_reverse_domain_name( - reverse_host_address) - d.create_reverse_domain(domain_name, - domain_reverse_name) - - # Delete the reverse zone - self.name = reverse_host_address - self.type = 'PTR' - self.data = record['content'] - self.delete(domain_reverse_name) return { 'status': 'ok', 'msg': 'Auto-PTR record was updated successfully' diff --git a/powerdnsadmin/models/role.py b/powerdnsadmin/models/role.py index a5cf530..5440f3d 100644 --- a/powerdnsadmin/models/role.py +++ b/powerdnsadmin/models/role.py @@ -5,7 +5,7 @@ class Role(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(64), index=True, unique=True) description = db.Column(db.String(128)) - users = db.relationship('User', backref='role', lazy=True) + users = db.relationship('User', back_populates='role', lazy=True) apikeys = db.relationship('ApiKey', back_populates='role', lazy=True) def __init__(self, id=None, name=None, description=None): @@ -20,4 +20,4 @@ class Role(db.Model): self.description = description def __repr__(self): - return ''.format(self.name) + return ''.format(self.name) diff --git a/powerdnsadmin/models/setting.py b/powerdnsadmin/models/setting.py index a4016bf..71fec98 100644 --- a/powerdnsadmin/models/setting.py +++ b/powerdnsadmin/models/setting.py @@ -11,7 +11,7 @@ from .base import db class Setting(db.Model): id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(64)) + name = db.Column(db.String(64), unique=True, index=True) value = db.Column(db.Text()) defaults = { @@ -31,6 +31,7 @@ class Setting(db.Model): 'delete_sso_accounts': False, 'bg_domain_updates': False, 'enable_api_rr_history': True, + 'preserve_history': False, 'site_name': 'PowerDNS-Admin', 'site_url': 'http://localhost:9191', 'session_timeout': 10, diff --git a/powerdnsadmin/models/user.py b/powerdnsadmin/models/user.py index 1e39569..78104f6 100644 --- a/powerdnsadmin/models/user.py +++ b/powerdnsadmin/models/user.py @@ -34,6 +34,7 @@ class User(db.Model): otp_secret = db.Column(db.String(16)) confirmed = db.Column(db.SmallInteger, nullable=False, default=0) role_id = db.Column(db.Integer, db.ForeignKey('role.id')) + role = db.relationship('Role', back_populates="users", lazy=True) accounts = None def __init__(self, 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 9474503..98f4ef9 100644 --- a/powerdnsadmin/routes/admin.py +++ b/powerdnsadmin/routes/admin.py @@ -610,14 +610,21 @@ def manage_user(): @operator_role_required def edit_account(account_name=None): users = User.query.all() + account = Account.query.filter( + Account.name == account_name).first() + all_accounts = Account.query.all() + accounts = {acc.id: acc for acc in all_accounts} + domains = Domain.query.all() if request.method == 'GET': - if account_name is None: + if account_name is None or not account: return render_template('admin_edit_account.html', + account=None, account_user_ids=[], users=users, + domains=domains, + accounts=accounts, create=1) - else: account = Account.query.filter( Account.name == account_name).first() @@ -626,11 +633,14 @@ def edit_account(account_name=None): account=account, account_user_ids=account_user_ids, users=users, + domains=domains, + accounts=accounts, create=0) if request.method == 'POST': fdata = request.form new_user_list = request.form.getlist('account_multi_user') + new_domain_list = request.form.getlist('account_domains') # on POST, synthesize account and account_user_ids from form data if not account_name: @@ -654,6 +664,8 @@ def edit_account(account_name=None): account=account, account_user_ids=account_user_ids, users=users, + domains=domains, + accounts=accounts, create=create, invalid_accountname=True) @@ -662,19 +674,33 @@ def edit_account(account_name=None): account=account, account_user_ids=account_user_ids, users=users, + domains=domains, + accounts=accounts, create=create, duplicate_accountname=True) result = account.create_account() - history = History(msg='Create account {0}'.format(account.name), - created_by=current_user.username) - else: result = account.update_account() - history = History(msg='Update account {0}'.format(account.name), - created_by=current_user.username) if result['status']: + account = Account.query.filter( + Account.name == account_name).first() + old_domains = Domain.query.filter(Domain.account_id == account.id).all() + + for domain_name in new_domain_list: + domain = Domain.query.filter( + Domain.name == domain_name).first() + if account.id != domain.account_id: + Domain(name=domain_name).assoc_account(account.id) + + for domain in old_domains: + if domain.name not in new_domain_list: + Domain(name=domain.name).assoc_account(None) + + history = History(msg='{0} account {1}'.format('Create' if create else 'Update', account.name), + created_by=current_user.username) + account.grant_privileges(new_user_list) history.add() return redirect(url_for('admin.manage_account')) @@ -769,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 }}
""", @@ -778,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 }}
@@ -890,6 +917,16 @@ class DetailedHistory(): ''', history_status=DetailedHistory.get_key_val(detail_dict, 'status'), history_msg=DetailedHistory.get_key_val(detail_dict, 'msg')) + + elif 'Update domain' in history.msg and 'associate account' in history.msg: # When an account gets associated or dissociate with domains + self.detailed_msg = render_template_string(''' + + + +
Associate: {{ history_assoc_account }}
Dissociate:{{ history_dissoc_account }}
+ ''', + history_assoc_account=DetailedHistory.get_key_val(detail_dict, 'assoc_account'), + history_dissoc_account=DetailedHistory.get_key_val(detail_dict, 'dissoc_account')) # check for lower key as well for old databases @staticmethod @@ -927,6 +964,13 @@ def history(): 'msg': 'You do not have permission to remove history.' }), 401) + if Setting().get('preserve_history'): + return make_response( + jsonify({ + 'status': 'error', + 'msg': 'History removal is not allowed (toggle preserve_history in settings).' + }), 401) + h = History() result = h.remove_all() if result: @@ -1282,6 +1326,7 @@ def setting_basic(): 'otp_field_enabled', 'otp_force', 'pdns_api_timeout', + 'preserve_history', 'pretty_ipv6_ptr', 'record_helper', 'record_quick_edit', diff --git a/powerdnsadmin/routes/api.py b/powerdnsadmin/routes/api.py index 0844c32..2c9a2cc 100644 --- a/powerdnsadmin/routes/api.py +++ b/powerdnsadmin/routes/api.py @@ -1,22 +1,21 @@ import json -from urllib.parse import urljoin +import secrets +import string from base64 import b64encode -from flask import ( - Blueprint, g, request, abort, current_app, make_response, jsonify, -) +from urllib.parse import urljoin + +from flask import (Blueprint, g, request, abort, current_app, make_response, jsonify) from flask_login import current_user from .base import csrf -from ..models.base import db -from ..models import ( - User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey, - Role, +from ..decorators import ( + api_basic_auth, api_can_create_domain, is_json, apikey_auth, + apikey_can_create_domain, apikey_can_remove_domain, + apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec, + api_role_can, apikey_or_basic_auth, + callback_if_request_body_contains_key, allowed_record_types, allowed_record_ttl ) from ..lib import utils, helper -from ..lib.schema import ( - ApiKeySchema, DomainSchema, ApiPlainKeySchema, UserSchema, AccountSchema, - UserDetailedSchema, -) from ..lib.errors import ( StructuredException, DomainNotExists, DomainAlreadyExists, DomainAccessForbidden, @@ -26,15 +25,15 @@ from ..lib.errors import ( UserCreateFail, UserCreateDuplicate, UserUpdateFail, UserDeleteFail, UserUpdateFailEmail, InvalidAccountNameException ) -from ..decorators import ( - api_basic_auth, api_can_create_domain, is_json, apikey_auth, - apikey_can_create_domain, apikey_can_remove_domain, - apikey_is_admin, apikey_can_access_domain, apikey_can_configure_dnssec, - api_role_can, apikey_or_basic_auth, - callback_if_request_body_contains_key, allowed_record_types, allowed_record_ttl +from ..lib.schema import ( + ApiKeySchema, DomainSchema, ApiPlainKeySchema, UserSchema, AccountSchema, + UserDetailedSchema, ) -import secrets -import string +from ..models import ( + User, Domain, DomainUser, Account, AccountUser, History, Setting, ApiKey, + Role, +) +from ..models.base import db api_bp = Blueprint('api', __name__, url_prefix='/api/v1') apilist_bp = Blueprint('apilist', __name__, url_prefix='/') @@ -56,10 +55,10 @@ def get_user_domains(): .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter( - db.or_( - DomainUser.user_id == current_user.id, - AccountUser.user_id == current_user.id - )).all() + db.or_( + DomainUser.user_id == current_user.id, + AccountUser.user_id == current_user.id + )).all() return domains @@ -71,10 +70,10 @@ def get_user_apikeys(domain_name=None): .outerjoin(Account, Domain.account_id == Account.id) \ .outerjoin(AccountUser, Account.id == AccountUser.account_id) \ .filter( - db.or_( - DomainUser.user_id == User.id, - AccountUser.user_id == User.id - ) + db.or_( + DomainUser.user_id == User.id, + AccountUser.user_id == User.id + ) ) \ .filter(User.id == current_user.id) @@ -167,12 +166,7 @@ def handle_request_is_not_json(err): def before_request(): # Check site is in maintenance mode maintenance = Setting().get('maintenance') - if ( - maintenance and current_user.is_authenticated and - current_user.role.name not in [ - 'Administrator', 'Operator' - ] - ): + if (maintenance and current_user.is_authenticated and current_user.role.name not in ['Administrator', 'Operator']): return make_response( jsonify({ "status": False, @@ -224,14 +218,13 @@ def api_login_create_zone(): history = History(msg='Add domain {0}'.format( data['name'].rstrip('.')), - detail=json.dumps(data), - created_by=current_user.username, - domain_id=domain_id) + detail=json.dumps(data), + created_by=current_user.username, + domain_id=domain_id) history.add() if current_user.role.name not in ['Administrator', 'Operator']: - current_app.logger.debug( - "User is ordinary user, assigning created domain") + current_app.logger.debug("User is ordinary user, assigning created domain") domain = Domain(name=data['name'].rstrip('.')) domain.update() domain.grant_privileges([current_user.id]) @@ -299,9 +292,9 @@ def api_login_delete_zone(domain_name): history = History(msg='Delete domain {0}'.format( utils.pretty_domain_name(domain_name)), - detail='', - created_by=current_user.username, - domain_id=domain_id) + detail='', + created_by=current_user.username, + domain_id=domain_id) history.add() except Exception as e: @@ -326,14 +319,14 @@ def api_generate_apikey(): if 'domains' not in data: domains = [] - elif not isinstance(data['domains'], (list, )): + elif not isinstance(data['domains'], (list,)): abort(400) else: domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] if 'accounts' not in data: accounts = [] - elif not isinstance(data['accounts'], (list, )): + elif not isinstance(data['accounts'], (list,)): abort(400) else: accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']] @@ -385,8 +378,7 @@ def api_generate_apikey(): user_domain_list = [item.name for item in user_domain_obj_list] current_app.logger.debug("Input domain list: {0}".format(domain_list)) - current_app.logger.debug( - "User domain list: {0}".format(user_domain_list)) + current_app.logger.debug("User domain list: {0}".format(user_domain_list)) inter = set(domain_list).intersection(set(user_domain_list)) @@ -539,14 +531,14 @@ def api_update_apikey(apikey_id): if 'domains' not in data: domains = None - elif not isinstance(data['domains'], (list, )): + elif not isinstance(data['domains'], (list,)): abort(400) else: domains = [d['name'] if isinstance(d, dict) else d for d in data['domains']] if 'accounts' not in data: accounts = None - elif not isinstance(data['accounts'], (list, )): + elif not isinstance(data['accounts'], (list,)): abort(400) else: accounts = [a['name'] if isinstance(a, dict) else a for a in data['accounts']] @@ -963,9 +955,7 @@ def api_delete_account(account_id): account = account_list[0] else: abort(404) - current_app.logger.debug( - f'Deleting Account {account.name}' - ) + current_app.logger.debug(f'Deleting Account {account.name}') # Remove account association from domains first if len(account.domains) > 0: @@ -1047,7 +1037,7 @@ def api_remove_account_user(account_id, user_id): user_list = User.query.join(AccountUser).filter( AccountUser.account_id == account_id, AccountUser.user_id == user_id, - ).all() + ).all() if not user_list: abort(404) if not account.remove_user(user): @@ -1194,17 +1184,13 @@ def api_get_zones(server_id): return jsonify(domain_schema.dump(domain_obj_list)), 200 else: resp = helper.forward_request() - if ( - g.apikey.role.name not in ['Administrator', 'Operator'] - and resp.status_code == 200 - ): + if (g.apikey.role.name not in ['Administrator', 'Operator'] and resp.status_code == 200): domain_list = [d['name'] for d in domain_schema.dump(g.apikey.domains)] accounts_domains = [d.name for a in g.apikey.accounts for d in a.domains] allowed_domains = set(domain_list + accounts_domains) - current_app.logger.debug("Account domains: {}".format( - '/'.join(accounts_domains))) + current_app.logger.debug("Account domains: {}".format('/'.join(accounts_domains))) content = json.dumps([i for i in json.loads(resp.content) if i['name'].rstrip('.') in allowed_domains]) return content, resp.status_code, resp.headers.items() @@ -1225,6 +1211,7 @@ def api_server_config_forward(server_id): resp = helper.forward_request() return resp.content, resp.status_code, resp.headers.items() + # The endpoint to synchronize Domains in background @api_bp.route('/sync_domains', methods=['GET']) @apikey_or_basic_auth @@ -1233,6 +1220,7 @@ def sync_domains(): domain.update() return 'Finished synchronization in background', 200 + @api_bp.route('/health', methods=['GET']) @apikey_auth def health(): @@ -1246,7 +1234,8 @@ def health(): try: domain.get_domain_info(domain_to_query.name) except Exception as e: - current_app.logger.error("Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name)) + current_app.logger.error( + "Health Check - Failed to query authoritative server for domain {}".format(domain_to_query.name)) return make_response("Down", 503) return make_response("Up", 200) 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/domain.py b/powerdnsadmin/routes/domain.py index 87d0be4..9742760 100644 --- a/powerdnsadmin/routes/domain.py +++ b/powerdnsadmin/routes/domain.py @@ -89,14 +89,14 @@ def domain(domain_name): # - Find a way to make it consistent, or # - Only allow one comment for that case if StrictVersion(Setting().get('pdns_version')) >= StrictVersion('4.0.0'): + pretty_v6 = Setting().get('pretty_ipv6_ptr') for r in rrsets: if r['type'] in records_allow_to_edit: r_name = r['name'].rstrip('.') # If it is reverse zone and pretty_ipv6_ptr setting # is enabled, we reformat the name for ipv6 records. - if Setting().get('pretty_ipv6_ptr') and r[ - 'type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name: + if pretty_v6 and r['type'] == 'PTR' and 'ip6.arpa' in r_name and '*' not in r_name: r_name = dns.reversename.to_address( dns.name.from_text(r_name)) diff --git a/powerdnsadmin/routes/index.py b/powerdnsadmin/routes/index.py index e6e8343..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) @@ -581,7 +581,7 @@ def get_azure_groups(uri): def authenticate_user(user, authenticator, remember=False): login_user(user, remember=remember) signin_history(user.username, authenticator, True) - if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret: + if Setting().get('otp_force') and Setting().get('otp_field_enabled') and not user.otp_secret and session['authentication_type'] not in ['OAuth']: user.update_profile(enable_otp=True) user_id = current_user.id prepare_welcome_user(user_id) @@ -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 1946bc9..046866c 100644 --- a/powerdnsadmin/templates/admin_edit_account.html +++ b/powerdnsadmin/templates/admin_edit_account.html @@ -1,166 +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.

-
- -
-
- - + {{ 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 %} - -