Răsfoiți Sursa

Form design for proxy hosts, audit log base

Jamie Curnow 7 ani în urmă
părinte
comite
c5450eaa1a

+ 52 - 0
src/backend/internal/audit-log.js

@@ -0,0 +1,52 @@
+'use strict';
+
+const auditLogModel = require('../models/audit-log');
+
+const internalAuditLog = {
+
+    /**
+     * Internal use only
+     *
+     * @param   {Object}  data
+     * @returns {Promise}
+     */
+    create: data => {
+        // TODO
+    },
+
+    /**
+     * All logs
+     *
+     * @param   {Access}  access
+     * @param   {Array}   [expand]
+     * @param   {String}  [search_query]
+     * @returns {Promise}
+     */
+    getAll: (access, expand, search_query) => {
+        return access.can('auditlog:list')
+            .then(() => {
+                let query = auditLogModel
+                    .query()
+                    .orderBy('created_on', 'DESC')
+                    .limit(100);
+
+                // Query is used for searching
+                if (typeof search_query === 'string') {
+                    /*
+                    query.where(function () {
+                        this.where('name', 'like', '%' + search_query + '%')
+                            .orWhere('email', 'like', '%' + search_query + '%');
+                    });
+                    */
+                }
+
+                if (typeof expand !== 'undefined' && expand !== null) {
+                    query.eager('[' + expand.join(', ') + ']');
+                }
+
+                return query;
+            });
+    }
+};
+
+module.exports = internalAuditLog;

+ 7 - 0
src/backend/lib/access/auditlog-list.json

@@ -0,0 +1,7 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    }
+  ]
+}

+ 13 - 0
src/backend/migrations/20180618015850_initial.js

@@ -157,6 +157,19 @@ exports.up = function (knex/*, Promise*/) {
         })
         .then(() => {
             logger.info('[' + migrate_name + '] access_list_auth Table created');
+
+            return knex.schema.createTable('audit_log', table => {
+                table.increments().primary();
+                table.dateTime('created_on').notNull();
+                table.dateTime('modified_on').notNull();
+                table.integer('user_id').notNull().unsigned();
+                // TODO
+                table.string('action').notNull();
+                table.json('meta').notNull();
+            });
+        })
+        .then(() => {
+            logger.info('[' + migrate_name + '] audit_log Table created');
         });
 
 };

+ 30 - 0
src/backend/models/audit-log.js

@@ -0,0 +1,30 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+
+Model.knex(db);
+
+class AuditLog extends Model {
+    $beforeInsert () {
+        this.created_on  = Model.raw('NOW()');
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    $beforeUpdate () {
+        this.modified_on = Model.raw('NOW()');
+    }
+
+    static get name () {
+        return 'AuditLog';
+    }
+
+    static get tableName () {
+        return 'audit_log';
+    }
+}
+
+module.exports = AuditLog;

+ 54 - 0
src/backend/routes/api/audit-log.js

@@ -0,0 +1,54 @@
+'use strict';
+
+const express          = require('express');
+const validator        = require('../../lib/validator');
+const jwtdecode        = require('../../lib/express/jwt-decode');
+const internalAuditLog = require('../../internal/audit-log');
+
+let router = express.Router({
+    caseSensitive: true,
+    strict:        true,
+    mergeParams:   true
+});
+
+/**
+ * /api/audit-log
+ */
+router
+    .route('/')
+    .options((req, res) => {
+        res.sendStatus(204);
+    })
+    .all(jwtdecode())
+
+    /**
+     * GET /api/audit-log
+     *
+     * Retrieve all logs
+     */
+    .get((req, res, next) => {
+        validator({
+            additionalProperties: false,
+            properties:           {
+                expand: {
+                    $ref: 'definitions#/definitions/expand'
+                },
+                query:  {
+                    $ref: 'definitions#/definitions/query'
+                }
+            }
+        }, {
+            expand: (typeof req.query.expand === 'string' ? req.query.expand.split(',') : null),
+            query:  (typeof req.query.query === 'string' ? req.query.query : null)
+        })
+            .then(data => {
+                return internalAuditLog.getAll(res.locals.access, data.expand, data.query);
+            })
+            .then(rows => {
+                res.status(200)
+                    .send(rows);
+            })
+            .catch(next);
+    });
+
+module.exports = router;

+ 1 - 0
src/backend/routes/api/main.js

@@ -29,6 +29,7 @@ router.get('/', (req, res/*, next*/) => {
 
 router.use('/tokens', require('./tokens'));
 router.use('/users', require('./users'));
+router.use('/audit-log', require('./audit-log'));
 router.use('/reports', require('./reports'));
 router.use('/nginx/proxy-hosts', require('./nginx/proxy_hosts'));
 router.use('/nginx/redirection-hosts', require('./nginx/redirection_hosts'));

+ 18 - 0
src/frontend/js/app/api.js

@@ -257,6 +257,13 @@ module.exports = {
              */
             getAll: function (expand, query) {
                 return getAllObjects('nginx/proxy-hosts', expand, query);
+            },
+
+            /**
+             * @param {Object}  data
+             */
+            create: function (data) {
+                return fetch('post', 'nginx/proxy-hosts', data);
             }
         },
 
@@ -305,6 +312,17 @@ module.exports = {
         }
     },
 
+    AuditLog: {
+        /**
+         * @param   {Array}    [expand]
+         * @param   {String}   [query]
+         * @returns {Promise}
+         */
+        getAll: function (expand, query) {
+            return getAllObjects('audit-log', expand, query);
+        }
+    },
+
     Reports: {
 
         /**

+ 32 - 0
src/frontend/js/app/audit-log/list/item.ejs

@@ -0,0 +1,32 @@
+<td class="text-center">
+    <div class="avatar d-block" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)">
+        <span class="avatar-status <%- is_disabled ? 'bg-red' : 'bg-green' %>"></span>
+    </div>
+</td>
+<td>
+    <div><%- name %></div>
+    <div class="small text-muted">
+        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+    </div>
+</td>
+<td>
+    <div><%- email %></div>
+</td>
+<td>
+    <div><%- roles.join(', ') %></div>
+</td>
+<td class="text-center">
+    <div class="item-action dropdown">
+        <a href="#" data-toggle="dropdown" class="icon"><i class="fe fe-more-vertical"></i></a>
+        <div class="dropdown-menu dropdown-menu-right">
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> Edit Details</a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> Edit Permissions</a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> Set Password</a>
+            <% if (!isSelf()) { %>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <div class="dropdown-divider"></div>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> Delete User</a>
+            <% } %>
+        </div>
+    </div>
+</td>

+ 72 - 0
src/frontend/js/app/audit-log/list/item.js

@@ -0,0 +1,72 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const Controller = require('../../controller');
+const Api        = require('../../api');
+const Cache      = require('../../cache');
+const Tokens     = require('../../tokens');
+const template   = require('./item.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    tagName:  'tr',
+
+    ui: {
+        edit:        'a.edit-user',
+        permissions: 'a.edit-permissions',
+        password:    'a.set-password',
+        login:       'a.login',
+        delete:      'a.delete-user'
+    },
+
+    events: {
+        'click @ui.edit': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(this.model);
+        },
+
+        'click @ui.permissions': function (e) {
+            e.preventDefault();
+            Controller.showUserPermissions(this.model);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(this.model);
+        },
+
+        'click @ui.delete': function (e) {
+            e.preventDefault();
+            Controller.showUserDeleteConfirm(this.model);
+        },
+
+        'click @ui.login': function (e) {
+            e.preventDefault();
+
+            if (Cache.User.get('id') !== this.model.get('id')) {
+                this.ui.login.prop('disabled', true).addClass('btn-disabled');
+
+                Api.Users.loginAs(this.model.get('id'))
+                    .then(res => {
+                        Tokens.addToken(res.token, res.user.nickname || res.user.name);
+                        window.location = '/';
+                        window.location.reload();
+                    })
+                    .catch(err => {
+                        alert(err.message);
+                        this.ui.login.prop('disabled', false).removeClass('btn-disabled');
+                    });
+            }
+        }
+    },
+
+    templateContext: {
+        isSelf: function () {
+            return Cache.User.get('id') === this.id;
+        }
+    },
+
+    initialize: function () {
+        this.listenTo(this.model, 'change', this.render);
+    }
+});

+ 10 - 0
src/frontend/js/app/audit-log/list/main.ejs

@@ -0,0 +1,10 @@
+<thead>
+<th width="30">&nbsp;</th>
+<th>Name</th>
+<th>Email</th>
+<th>Roles</th>
+<th>&nbsp;</th>
+</thead>
+<tbody>
+<!-- items -->
+</tbody>

+ 29 - 0
src/frontend/js/app/audit-log/list/main.js

@@ -0,0 +1,29 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const ItemView = require('./item');
+const template = require('./main.ejs');
+
+const TableBody = Mn.CollectionView.extend({
+    tagName:   'tbody',
+    childView: ItemView
+});
+
+module.exports = Mn.View.extend({
+    tagName:   'table',
+    className: 'table table-hover table-outline table-vcenter text-nowrap card-table',
+    template:  template,
+
+    regions: {
+        body: {
+            el:             'tbody',
+            replaceElement: true
+        }
+    },
+
+    onRender: function () {
+        this.showChildView('body', new TableBody({
+            collection: this.collection
+        }));
+    }
+});

+ 14 - 0
src/frontend/js/app/audit-log/main.ejs

@@ -0,0 +1,14 @@
+<div class="card">
+    <div class="card-header">
+        <h3 class="card-title">Audit Log</h3>
+    </div>
+    <div class="card-body no-padding min-100">
+        <div class="dimmer active">
+            <div class="loader"></div>
+            <div class="dimmer-content list-region">
+                <!-- List Region -->
+            </div>
+        </div>
+
+    </div>
+</div>

+ 56 - 0
src/frontend/js/app/audit-log/main.js

@@ -0,0 +1,56 @@
+'use strict';
+
+const Mn            = require('backbone.marionette');
+const AuditLogModel = require('../../models/audit-log');
+const Api           = require('../api');
+const Controller    = require('../controller');
+const ListView      = require('./list/main');
+const template      = require('./main.ejs');
+const ErrorView     = require('../error/main');
+const EmptyView     = require('../empty/main');
+
+module.exports = Mn.View.extend({
+    id:       'audit-log',
+    template: template,
+
+    ui: {
+        list_region: '.list-region',
+        dimmer:      '.dimmer'
+    },
+
+    regions: {
+        list_region: '@ui.list_region'
+    },
+
+    onRender: function () {
+        let view = this;
+
+        Api.AuditLog.getAll()
+            .then(response => {
+                if (!view.isDestroyed() && response && response.length) {
+                    view.showChildView('list_region', new ListView({
+                        collection: new AuditLogModel.Collection(response)
+                    }));
+                } else {
+                    view.showChildView('list_region', new EmptyView({
+                        title:    'There are no logs.',
+                        subtitle: 'As soon as you or another user changes something, history of those events will show up here.'
+                    }));
+                }
+            })
+            .catch(err => {
+                view.showChildView('list_region', new ErrorView({
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        Controller.showAuditLog();
+                    }
+                }));
+
+                console.error(err);
+            })
+            .then(() => {
+                view.ui.dimmer.removeClass('active');
+            });
+    }
+});

+ 10 - 7
src/frontend/js/app/controller.js

@@ -204,15 +204,18 @@ module.exports = {
     },
 
     /**
-     * Dashboard
+     * Audit Log
      */
-    showProfile: function () {
+    showAuditLog: function () {
         let controller = this;
-
-        require(['./main', './profile/main'], (App, View) => {
-            controller.navigate('/profile');
-            App.UI.showAppContent(new View());
-        });
+        if (Cache.User.isAdmin()) {
+            require(['./main', './audit-log/main'], (App, View) => {
+                controller.navigate('/audit-log');
+                App.UI.showAppContent(new View());
+            });
+        } else {
+            this.showDashboard();
+        }
     },
 
     /**

+ 5 - 0
src/frontend/js/app/main.js

@@ -47,6 +47,11 @@ const App = Mn.Application.extend({
                 this.UI.on('render', () => {
                     new Router(options);
                     Backbone.history.start({pushState: true});
+
+                    // Ask the admin use to change their details
+                    if (Cache.User.get('email') === '[email protected]') {
+                        Controller.showUserForm(Cache.User);
+                    }
                 });
 
                 this.getRegion().show(this.UI);

+ 103 - 16
src/frontend/js/app/nginx/proxy/form.ejs

@@ -3,28 +3,115 @@
         <h5 class="modal-title"><% if (typeof id !== 'undefined') { %>Edit<% } else { %>New<% } %> Proxy Host</h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
-    <div class="modal-body">
+    <div class="modal-body has-tabs">
         <form>
-            <div class="row">
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">Domain Name <span class="form-required">*</span></label>
-                        <input name="domain_name" type="text" class="form-control" placeholder="example.com" value="<%- domain_name %>" required>
+            <ul class="nav nav-tabs" role="tablist">
+                <li role="presentation" class="nav-item"><a href="#details" aria-controls="tab1" role="tab" data-toggle="tab" class="nav-link active"><i class="fe fe-zap"></i> Details</a></li>
+                <li role="presentation" class="nav-item"><a href="#ssl-options" aria-controls="tab2" role="tab" data-toggle="tab" class="nav-link"><i class="fe fe-shield"></i> SSL</a></li>
+            </ul>
+            <div class="tab-content">
+                <!-- Details -->
+                <div role="tabpanel" class="tab-pane active" id="details">
+                    <div class="row">
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label">Domain Name <span class="form-required">*</span></label>
+                                <input name="domain_name" type="text" class="form-control" placeholder="example.com or *.example.com" value="<%- domain_name %>" pattern="(\*\.)?[a-z0-9\.]+" required title="Please enter a valid domain name. Domain wildcards are allowed: *.yourdomain.com">
+                            </div>
+                        </div>
+                        <div class="col-sm-8 col-md-8">
+                            <div class="form-group">
+                                <label class="form-label">Forward IP <span class="form-required">*</span></label>
+                                <input type="text" name="forward_ip" class="form-control" placeholder="000.000.000.000" autocomplete="off" maxlength="15" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-4 col-md-4">
+                            <div class="form-group">
+                                <label class="form-label">Forward Port <span class="form-required">*</span></label>
+                                <input name="forward_port" type="number" class="form-control" placeholder="80" value="<%- forward_port %>" required>
+                            </div>
+                        </div>
                     </div>
                 </div>
-                <div class="col-sm-8 col-md-8">
-                    <div class="form-group">
-                        <label class="form-label">Forward IP <span class="form-required">*</span></label>
-                        <input type="text" name="forward_ip" class="form-control" placeholder="000.000.000.000" autocomplete="off" maxlength="15" required>
-                    </div>
-                </div>
-                <div class="col-sm-4 col-md-4">
-                    <div class="form-group">
-                        <label class="form-label">Forward Port <span class="form-required">*</span></label>
-                        <input name="forward_port" type="number" class="form-control" placeholder="80" value="<%- forward_port %>" required>
+
+                <!-- SSL -->
+                <div role="tabpanel" class="tab-pane" id="ssl-options">
+                    <div class="row">
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_enabled" value="1"<%- ssl_enabled ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description">Enable SSL</span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-6 col-md-6">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="ssl_forced" value="1"<%- ssl_forced ? ' checked' : '' %><%- ssl_enabled ? '' : ' disabled' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description">Force SSL</span>
+                                </label>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12">
+                            <div class="form-group">
+                                <label class="form-label">Certificate Provider</label>
+                                <div class="selectgroup w-100">
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="letsencrypt" class="selectgroup-input"<%- ssl_provider !== 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button">Let's Encrypt</span>
+                                    </label>
+                                    <label class="selectgroup-item">
+                                        <input type="radio" name="ssl_provider" value="other" class="selectgroup-input"<%- ssl_provider === 'other' ? ' checked' : '' %>>
+                                        <span class="selectgroup-button">Other</span>
+                                    </label>
+                                </div>
+                            </div>
+                        </div>
+
+                        <!-- Lets encrypt -->
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="form-label">Email Address for Let's Encrypt <span class="form-required">*</span></label>
+                                <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 letsencrypt-ssl">
+                            <div class="form-group">
+                                <label class="custom-switch">
+                                    <input type="checkbox" class="custom-switch-input" name="meta[letsencrypt_agree]" value="1" required<%- getLetsencryptAgree() ? ' checked' : '' %>>
+                                    <span class="custom-switch-indicator"></span>
+                                    <span class="custom-switch-description">I Agree to the <a href="https://letsencrypt.org/repository/" target="_blank">Let's Encrypt Terms of Service</a> <span class="form-required">*</span></span>
+                                </label>
+                            </div>
+                        </div>
+
+                        <!-- Other -->
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label">Certificate</div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate]">
+                                    <label class="custom-file-label">Choose file</label>
+                                </div>
+                            </div>
+                        </div>
+                        <div class="col-sm-12 col-md-12 other-ssl">
+                            <div class="form-group">
+                                <div class="form-label">Certificate Key</div>
+                                <div class="custom-file">
+                                    <input type="file" class="custom-file-input" name="meta[other_ssl_certificate_key]">
+                                    <label class="custom-file-label">Choose file</label>
+                                </div>
+                            </div>
+                        </div>
                     </div>
                 </div>
             </div>
+
+
         </form>
     </div>
     <div class="modal-footer">

+ 68 - 35
src/frontend/js/app/nginx/proxy/form.js

@@ -1,5 +1,6 @@
 'use strict';
 
+const _              = require('underscore');
 const Mn             = require('backbone.marionette');
 const template       = require('./form.ejs');
 const Controller     = require('../../controller');
@@ -16,78 +17,110 @@ module.exports = Mn.View.extend({
     className: 'modal-dialog',
 
     ui: {
-        form:       'form',
-        forward_ip: 'input[name="forward_ip"]',
-        buttons:    '.modal-footer button',
-        cancel:     'button.cancel',
-        save:       'button.save',
-        error:      '.secret-error'
+        form:         'form',
+        domain_name:  'input[name="domain_name"]',
+        forward_ip:   'input[name="forward_ip"]',
+        buttons:      '.modal-footer button',
+        cancel:       'button.cancel',
+        save:         'button.save',
+        ssl_enabled:  'input[name="ssl_enabled"]',
+        ssl_options:  '#ssl-options input',
+        ssl_provider: 'input[name="ssl_provider"]',
+
+        // SSL hiding and showing
+        all_ssl:         '.letsencrypt-ssl, .other-ssl',
+        letsencrypt_ssl: '.letsencrypt-ssl',
+        other_ssl:       '.other-ssl'
     },
 
     events: {
+        'change @ui.ssl_enabled': function () {
+            let enabled = this.ui.ssl_enabled.prop('checked');
+            this.ui.ssl_options.not(this.ui.ssl_enabled).prop('disabled', !enabled).parents('.form-group').css('opacity', enabled ? 1 : 0.5);
+            this.ui.ssl_provider.trigger('change');
+        },
+
+        'change @ui.ssl_provider': function () {
+            let enabled  = this.ui.ssl_enabled.prop('checked');
+            let provider = this.ui.ssl_provider.filter(':checked').val();
+            this.ui.all_ssl.hide().find('input').prop('disabled', true);
+            this.ui[provider + '_ssl'].show().find('input').prop('disabled', !enabled);
+        },
 
         'click @ui.save': function (e) {
             e.preventDefault();
-            return;
 
-            this.ui.error.hide();
+            if (!this.ui.form[0].checkValidity()) {
+                $('<input type="submit">').hide().appendTo(this.ui.form).click().remove();
+                return;
+            }
+
             let view = this;
             let data = this.ui.form.serializeJSON();
 
             // Manipulate
-            data.roles = [];
-            if ((this.model.get('id') === Cache.User.get('id') && this.model.isAdmin()) || (typeof data.is_admin !== 'undefined' && data.is_admin)) {
-                data.roles.push('admin');
-                delete data.is_admin;
-            }
+            data.forward_port = parseInt(data.forward_port, 10);
+            _.map(data, function (item, idx) {
+                if (typeof item === 'string' && item === '1') {
+                    item = true;
+                } else if (typeof item === 'object' && item !== null) {
+                    _.map(item, function (item2, idx2) {
+                        if (typeof item2 === 'string' && item2 === '1') {
+                            item[idx2] = true;
+                        }
+                    });
+                }
+                data[idx] = item;
+            });
 
-            data.is_disabled = typeof data.is_disabled !== 'undefined' ? !!data.is_disabled : false;
+            // Process
             this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
-            let method = Api.Users.create;
+            let method = Api.Nginx.ProxyHosts.create;
 
             if (this.model.get('id')) {
                 // edit
-                method  = Api.Users.update;
+                method  = Api.Nginx.ProxyHosts.update;
                 data.id = this.model.get('id');
             }
 
             method(data)
                 .then(result => {
-                    if (result.id === Cache.User.get('id')) {
-                        Cache.User.set(result);
-                    }
-
-                    if (view.model.get('id') !== Cache.User.get('id')) {
-                        Controller.showUsers();
-                    }
-
                     view.model.set(result);
                     App.UI.closeModal(function () {
-                        if (method === Api.Users.create) {
-                            // Show permissions dialog immediately
-                            Controller.showUserPermissions(view.model);
+                        if (method === Api.Nginx.ProxyHosts.create) {
+                            Controller.showNginxProxy();
                         }
                     });
                 })
                 .catch(err => {
-                    this.ui.error.text(err.message).show();
+                    alert(err.message);
                     this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
                 });
         }
     },
 
+    templateContext: {
+        getLetsencryptEmail: function () {
+            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : Cache.User.get('email');
+        },
+
+        getLetsencryptAgree: function () {
+            return typeof this.meta.letsencrypt_agree !== 'undefined' ? this.meta.letsencrypt_agree : false;
+        }
+    },
+
     onRender: function () {
         this.ui.forward_ip.mask('099.099.099.099', {
             clearIfNotMatch: true,
             placeholder:     '000.000.000.000'
         });
-        /*
-        this.ui.forward_ip.mask('099.099.099.099', {
-            reverse:         true,
-            clearIfNotMatch: true,
-            placeholder:     '000.000.000.000'
-        });
-        */
+
+        this.ui.ssl_enabled.trigger('change');
+        this.ui.ssl_provider.trigger('change');
+
+        this.ui.domain_name[0].oninvalid = function () {
+            this.setCustomValidity('Please enter a valid domain name. Domain wildcards are allowed: *.yourdomain.com');
+        };
     },
 
     initialize: function (options) {

+ 0 - 33
src/frontend/js/app/profile/main.ejs

@@ -1,33 +0,0 @@
-<div class="row">
-
-    <div class="col-lg-4 col-md-6 col-xs-12">
-        <div class="card">
-            <div class="card-header">
-                <h3 class="card-title">My Profile</h3>
-            </div>
-            <div class="card-body">
-                <form>
-                    <div class="row">
-                        <div class="col-auto">
-                            <span class="avatar avatar-xl" style="background-image: url(<%- avatar || '/images/default-avatar.jpg' %>)"></span>
-                        </div>
-                        <div class="col">
-                            <div class="form-group">
-                                <label class="form-label">Name</label>
-                                <input name="name" class="form-control" value="<%- name %>">
-                            </div>
-                        </div>
-                    </div>
-                    <div class="form-group">
-                        <label class="form-label">Email-Address</label>
-                        <input name="email" class="form-control" value="<%- email %>">
-                    </div>
-                    <div class="form-footer">
-                        <button class="btn btn-primary btn-block">Save</button>
-                    </div>
-                </form>
-            </div>
-        </div>
-    </div>
-
-</div>

+ 0 - 21
src/frontend/js/app/profile/main.js

@@ -1,21 +0,0 @@
-'use strict';
-
-const Mn       = require('backbone.marionette');
-const Cache    = require('../cache');
-const template = require('./main.ejs');
-
-module.exports = Mn.View.extend({
-    template: template,
-    id:       'profile',
-
-    templateContext: {
-        getUserField: function (field, default_val) {
-            return Cache.User.get(field) || default_val;
-        }
-    },
-
-    initialize: function () {
-        this.model = Cache.User;
-    }
-});
-

+ 1 - 1
src/frontend/js/app/router.js

@@ -6,13 +6,13 @@ const Controller = require('./controller');
 module.exports = Mn.AppRouter.extend({
     appRoutes: {
         users:               'showUsers',
-        profile:             'showProfile',
         logout:              'logout',
         'nginx/proxy':       'showNginxProxy',
         'nginx/redirection': 'showNginxRedirection',
         'nginx/404':         'showNginxDead',
         'nginx/stream':      'showNginxStream',
         'nginx/access':      'showNginxAccess',
+        'audit-log':         'showAuditLog',
         '*default':          'showDashboard'
     },
 

+ 5 - 2
src/frontend/js/app/ui/header/main.ejs

@@ -14,8 +14,11 @@
                     </span>
                 </a>
                 <div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
-                    <a class="dropdown-item profile" href="/profile">
-                        <i class="dropdown-icon fe fe-user"></i> Profile
+                    <a class="dropdown-item edit-details" href="#">
+                        <i class="dropdown-icon fe fe-user"></i> Edit Details
+                    </a>
+                    <a class="dropdown-item change-password" href="#">
+                        <i class="dropdown-icon fe fe-lock"></i> Change Password
                     </a>
                     <div class="dropdown-divider"></div>
                     <a class="dropdown-item logout" href="/logout">

+ 13 - 4
src/frontend/js/app/ui/header/main.js

@@ -13,10 +13,22 @@ module.exports = Mn.View.extend({
     template:  template,
 
     ui: {
-        link: 'a'
+        link:     'a',
+        details:  'a.edit-details',
+        password: 'a.change-password'
     },
 
     events: {
+        'click @ui.details': function (e) {
+            e.preventDefault();
+            Controller.showUserForm(Cache.User);
+        },
+
+        'click @ui.password': function (e) {
+            e.preventDefault();
+            Controller.showUserPasswordForm(Cache.User);
+        },
+
         'click @ui.link': function (e) {
             e.preventDefault();
             let href = $(e.currentTarget).attr('href');
@@ -25,9 +37,6 @@ module.exports = Mn.View.extend({
                 case '/':
                     Controller.showDashboard();
                     break;
-                case '/profile':
-                    Controller.showProfile();
-                    break;
                 case '/logout':
                     Controller.logout();
                     break;

+ 4 - 1
src/frontend/js/app/ui/menu/main.ejs

@@ -30,10 +30,13 @@
                     <a href="/nginx/access" class="nav-link"><i class="fe fe-lock"></i> Access Lists</a>
                 </li>
                 <% } %>
-                <% if (showUsers()) { %>
+                <% if (isAdmin()) { %>
                 <li class="nav-item">
                     <a href="/users" class="nav-link"><i class="fe fe-users"></i> Users</a>
                 </li>
+                <li class="nav-item">
+                    <a href="/audit-log" class="nav-link"><i class="fe fe-book-open"></i> Audit Log</a>
+                </li>
                 <% } %>
             </ul>
         </div>

+ 1 - 1
src/frontend/js/app/ui/menu/main.js

@@ -26,7 +26,7 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
-        showUsers: function () {
+        isAdmin: function () {
             return Cache.User.isAdmin();
         },
 

+ 2 - 0
src/frontend/js/app/user/form.ejs

@@ -25,6 +25,7 @@
                         <div class="invalid-feedback secret-error"></div>
                     </div>
                 </div>
+                <% if (isAdmin()) { %>
                 <div class="col-sm-12 col-md-12">
                     <div class="form-label">Roles</div>
                 </div>
@@ -46,6 +47,7 @@
                         </label>
                     </div>
                 </div>
+                <% } %>
             </div>
         </form>
     </div>

+ 11 - 0
src/frontend/js/app/user/form.js

@@ -30,6 +30,15 @@ module.exports = Mn.View.extend({
             let view = this;
             let data = this.ui.form.serializeJSON();
 
+            let show_password = this.model.get('email') === '[email protected]';
+
+            // [email protected] is not allowed
+            if (data.email === '[email protected]') {
+                this.ui.error.text('Default email address must be changed').show();
+                this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                return;
+            }
+
             // Manipulate
             data.roles = [];
             if ((this.model.get('id') === Cache.User.get('id') && this.model.isAdmin()) || (typeof data.is_admin !== 'undefined' && data.is_admin)) {
@@ -62,6 +71,8 @@ module.exports = Mn.View.extend({
                         if (method === Api.Users.create) {
                             // Show permissions dialog immediately
                             Controller.showUserPermissions(view.model);
+                        } else if (show_password) {
+                            Controller.showUserPasswordForm(view.model);
                         }
                     });
                 })

+ 1 - 0
src/frontend/js/app/users/main.js

@@ -6,6 +6,7 @@ const Api        = require('../api');
 const Controller = require('../controller');
 const ListView   = require('./list/main');
 const template   = require('./main.ejs');
+const ErrorView  = require('../error/main');
 
 module.exports = Mn.View.extend({
     id:       'users',

+ 20 - 0
src/frontend/js/models/audit-log.js

@@ -0,0 +1,20 @@
+'use strict';
+
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            name: ''
+        };
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 14 - 0
src/frontend/scss/tabler-extra.scss

@@ -72,3 +72,17 @@ $blue: #467fcf;
 .dimmer .loader {
     margin-top: 50px;
 }
+
+/* modal tabs */
+
+.modal-body.has-tabs {
+    padding: 0;
+
+    .nav-tabs {
+        margin: 0;
+    }
+
+    .tab-content {
+        padding: 1rem;
+    }
+}