diff --git a/src/backend/internal/audit-log.js b/src/backend/internal/audit-log.js new file mode 100644 index 00000000..cb4b8c1d --- /dev/null +++ b/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; diff --git a/src/backend/lib/access/auditlog-list.json b/src/backend/lib/access/auditlog-list.json new file mode 100644 index 00000000..d2709fd8 --- /dev/null +++ b/src/backend/lib/access/auditlog-list.json @@ -0,0 +1,7 @@ +{ + "anyOf": [ + { + "$ref": "roles#/definitions/admin" + } + ] +} diff --git a/src/backend/migrations/20180618015850_initial.js b/src/backend/migrations/20180618015850_initial.js index 573b9c38..db29e156 100644 --- a/src/backend/migrations/20180618015850_initial.js +++ b/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'); }); }; diff --git a/src/backend/models/audit-log.js b/src/backend/models/audit-log.js new file mode 100644 index 00000000..e60273c8 --- /dev/null +++ b/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; diff --git a/src/backend/routes/api/audit-log.js b/src/backend/routes/api/audit-log.js new file mode 100644 index 00000000..36a0357f --- /dev/null +++ b/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; diff --git a/src/backend/routes/api/main.js b/src/backend/routes/api/main.js index 814a0fdd..1f6fb921 100644 --- a/src/backend/routes/api/main.js +++ b/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')); diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js index d6cb348b..b24d6fa9 100644 --- a/src/frontend/js/app/api.js +++ b/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: { /** diff --git a/src/frontend/js/app/audit-log/list/item.ejs b/src/frontend/js/app/audit-log/list/item.ejs new file mode 100644 index 00000000..bd4d19e0 --- /dev/null +++ b/src/frontend/js/app/audit-log/list/item.ejs @@ -0,0 +1,32 @@ + +
+ +
+ + +
<%- name %>
+
+ Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %> +
+ + +
<%- email %>
+ + +
<%- roles.join(', ') %>
+ + + + diff --git a/src/frontend/js/app/audit-log/list/item.js b/src/frontend/js/app/audit-log/list/item.js new file mode 100644 index 00000000..6766f088 --- /dev/null +++ b/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); + } +}); diff --git a/src/frontend/js/app/audit-log/list/main.ejs b/src/frontend/js/app/audit-log/list/main.ejs new file mode 100644 index 00000000..83afd4be --- /dev/null +++ b/src/frontend/js/app/audit-log/list/main.ejs @@ -0,0 +1,10 @@ + +  +Name +Email +Roles +  + + + + diff --git a/src/frontend/js/app/audit-log/list/main.js b/src/frontend/js/app/audit-log/list/main.js new file mode 100644 index 00000000..bbe75beb --- /dev/null +++ b/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 + })); + } +}); diff --git a/src/frontend/js/app/audit-log/main.ejs b/src/frontend/js/app/audit-log/main.ejs new file mode 100644 index 00000000..bb8b534d --- /dev/null +++ b/src/frontend/js/app/audit-log/main.ejs @@ -0,0 +1,14 @@ +
+
+

Audit Log

+
+
+
+
+
+ +
+
+ +
+
diff --git a/src/frontend/js/app/audit-log/main.js b/src/frontend/js/app/audit-log/main.js new file mode 100644 index 00000000..3ad81410 --- /dev/null +++ b/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'); + }); + } +}); diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js index 2365f90c..7de92490 100644 --- a/src/frontend/js/app/controller.js +++ b/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(); + } }, /** diff --git a/src/frontend/js/app/main.js b/src/frontend/js/app/main.js index b3d80bbb..b0104688 100644 --- a/src/frontend/js/app/main.js +++ b/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') === 'admin@example.com') { + Controller.showUserForm(Cache.User); + } }); this.getRegion().show(this.UI); diff --git a/src/frontend/js/app/nginx/proxy/form.ejs b/src/frontend/js/app/nginx/proxy/form.ejs index f0e6bce5..df94dd60 100644 --- a/src/frontend/js/app/nginx/proxy/form.ejs +++ b/src/frontend/js/app/nginx/proxy/form.ejs @@ -3,28 +3,115 @@ -