浏览代码

Merge branch 'v2-rewrite' of github.com:jc21/nginx-proxy-manager into v2-rewrite

Jamie Curnow 7 年之前
父节点
当前提交
2daa471a19
共有 71 个文件被更改,包括 1124 次插入722 次删除
  1. 2 0
      docker-compose.yml
  2. 2 2
      src/backend/internal/dead-host.js
  3. 96 0
      src/backend/internal/host.js
  4. 68 102
      src/backend/internal/proxy-host.js
  5. 2 2
      src/backend/internal/redirection-host.js
  6. 1 0
      src/backend/internal/user.js
  7. 2 2
      src/backend/lib/access.js
  8. 23 0
      src/backend/lib/access/proxy_hosts-create.json
  9. 23 0
      src/backend/lib/access/proxy_hosts-delete.json
  10. 23 0
      src/backend/lib/access/proxy_hosts-get.json
  11. 23 0
      src/backend/lib/access/proxy_hosts-update.json
  12. 3 3
      src/backend/migrations/20180618015850_initial.js
  13. 52 0
      src/backend/models/access_list.js
  14. 51 0
      src/backend/models/access_list_auth.js
  15. 5 1
      src/backend/models/dead_host.js
  16. 26 5
      src/backend/models/proxy_host.js
  17. 5 1
      src/backend/models/redirection_host.js
  18. 5 1
      src/backend/models/stream.js
  19. 3 3
      src/backend/routes/api/nginx/proxy_hosts.js
  20. 25 0
      src/backend/schema/definitions.json
  21. 105 101
      src/backend/schema/endpoints/proxy-hosts.json
  22. 19 0
      src/frontend/js/app/api.js
  23. 6 6
      src/frontend/js/app/audit-log/list/main.ejs
  24. 1 1
      src/frontend/js/app/audit-log/main.ejs
  25. 5 6
      src/frontend/js/app/audit-log/main.js
  26. 3 1
      src/frontend/js/app/cache.js
  27. 13 0
      src/frontend/js/app/controller.js
  28. 5 5
      src/frontend/js/app/dashboard/main.ejs
  29. 1 1
      src/frontend/js/app/error/main.ejs
  30. 25 0
      src/frontend/js/app/i18n.js
  31. 7 7
      src/frontend/js/app/main.js
  32. 24 0
      src/frontend/js/app/nginx/proxy/delete.ejs
  33. 36 0
      src/frontend/js/app/nginx/proxy/delete.js
  34. 23 22
      src/frontend/js/app/nginx/proxy/form.ejs
  35. 24 13
      src/frontend/js/app/nginx/proxy/form.js
  36. 21 13
      src/frontend/js/app/nginx/proxy/list/item.ejs
  37. 8 45
      src/frontend/js/app/nginx/proxy/list/item.js
  38. 6 3
      src/frontend/js/app/nginx/proxy/list/main.ejs
  39. 8 3
      src/frontend/js/app/nginx/proxy/list/main.js
  40. 2 2
      src/frontend/js/app/nginx/proxy/main.ejs
  41. 11 13
      src/frontend/js/app/nginx/proxy/main.js
  42. 4 2
      src/frontend/js/app/ui/footer/main.ejs
  43. 2 2
      src/frontend/js/app/ui/footer/main.js
  44. 4 4
      src/frontend/js/app/ui/header/main.ejs
  45. 4 3
      src/frontend/js/app/ui/header/main.js
  46. 9 9
      src/frontend/js/app/ui/menu/main.ejs
  47. 4 4
      src/frontend/js/app/user/delete.ejs
  48. 5 7
      src/frontend/js/app/user/delete.js
  49. 9 9
      src/frontend/js/app/user/form.ejs
  50. 17 20
      src/frontend/js/app/user/form.js
  51. 6 6
      src/frontend/js/app/user/password.ejs
  52. 6 9
      src/frontend/js/app/user/password.js
  53. 32 104
      src/frontend/js/app/user/permissions.ejs
  54. 7 10
      src/frontend/js/app/user/permissions.js
  55. 17 7
      src/frontend/js/app/users/list/item.ejs
  56. 11 13
      src/frontend/js/app/users/list/item.js
  57. 3 3
      src/frontend/js/app/users/list/main.ejs
  58. 3 3
      src/frontend/js/app/users/list/main.js
  59. 2 2
      src/frontend/js/app/users/main.ejs
  60. 13 12
      src/frontend/js/app/users/main.js
  61. 128 0
      src/frontend/js/i18n/messages.json
  62. 1 23
      src/frontend/js/lib/helpers.js
  63. 5 89
      src/frontend/js/lib/marionette.js
  64. 7 7
      src/frontend/js/login/ui/login.ejs
  65. 2 0
      src/frontend/js/login/ui/login.js
  66. 8 7
      src/frontend/js/models/dead-host.js
  67. 6 3
      src/frontend/js/models/proxy-host.js
  68. 1 0
      src/frontend/js/models/redirection-host.js
  69. 1 0
      src/frontend/js/models/stream.js
  70. 1 0
      src/frontend/js/models/user.js
  71. 13 0
      webpack.config.js

+ 2 - 0
docker-compose.yml

@@ -12,6 +12,8 @@ services:
     volumes:
       - ./data/letsencrypt:/etc/letsencrypt
       - .:/srv/app
+      - ~/.yarnrc:/root/.yarnrc
+      - ~/.npmrc:/root/.npmrc
     depends_on:
       - db
     links:

+ 2 - 2
src/backend/internal/dead-host.js

@@ -26,7 +26,7 @@ const internalDeadHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .orderBy('domain_name', 'ASC');
+                    .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
                     query.andWhere('owner_user_id', access.token.get('attrs').id);
@@ -35,7 +35,7 @@ const internalDeadHost = {
                 // Query is used for searching
                 if (typeof search_query === 'string') {
                     query.where(function () {
-                        this.where('domain_name', 'like', '%' + search_query + '%');
+                        this.where('domain_names', 'like', '%' + search_query + '%');
                     });
                 }
 

+ 96 - 0
src/backend/internal/host.js

@@ -0,0 +1,96 @@
+'use strict';
+
+const _                    = require('lodash');
+const error                = require('../lib/error');
+const proxyHostModel       = require('../models/proxy_host');
+const redirectionHostModel = require('../models/redirection_host');
+const deadHostModel        = require('../models/dead_host');
+
+const internalHost = {
+
+    /**
+     * Internal use only, checks to see if the domain is already taken by any other record
+     *
+     * @param   {String}   hostname
+     * @param   {String}   [ignore_type]   'proxy', 'redirection', 'dead'
+     * @param   {Integer}  [ignore_id]     Must be supplied if type was also supplied
+     * @returns {Promise}
+     */
+    isHostnameTaken: function (hostname, ignore_type, ignore_id) {
+        let promises = [
+            proxyHostModel
+                .query()
+                .where('is_deleted', 0)
+                .andWhere('domain_names', 'like', '%' + hostname + '%'),
+            redirectionHostModel
+                .query()
+                .where('is_deleted', 0)
+                .andWhere('domain_names', 'like', '%' + hostname + '%'),
+            deadHostModel
+                .query()
+                .where('is_deleted', 0)
+                .andWhere('domain_names', 'like', '%' + hostname + '%')
+        ];
+
+        return Promise.all(promises)
+            .then(promises_results => {
+                let is_taken = false;
+
+                if (promises_results[0]) {
+                    // Proxy Hosts
+                    if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[0], ignore_type === 'proxy' && ignore_id ? ignore_id : 0)) {
+                        is_taken = true;
+                    }
+                }
+
+                if (promises_results[1]) {
+                    // Redirection Hosts
+                    if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[1], ignore_type === 'redirection' && ignore_id ? ignore_id : 0)) {
+                        is_taken = true;
+                    }
+                }
+
+                if (promises_results[1]) {
+                    // Dead Hosts
+                    if (internalHost._checkHostnameRecordsTaken(hostname, promises_results[2], ignore_type === 'dead' && ignore_id ? ignore_id : 0)) {
+                        is_taken = true;
+                    }
+                }
+
+                return {
+                    hostname: hostname,
+                    is_taken: is_taken
+                };
+            });
+    },
+
+    /**
+     * Private call only
+     *
+     * @param   {String}  hostname
+     * @param   {Array}   existing_rows
+     * @param   {Integer} [ignore_id]
+     * @returns {Boolean}
+     */
+    _checkHostnameRecordsTaken: function (hostname, existing_rows, ignore_id) {
+        let is_taken = false;
+
+        if (existing_rows && existing_rows.length) {
+            existing_rows.map(function (existing_row) {
+                existing_row.domain_names.map(function (existing_hostname) {
+                    // Does this domain match?
+                    if (existing_hostname.toLowerCase() === hostname.toLowerCase()) {
+                        if (!ignore_id || ignore_id !== existing_row.id) {
+                            is_taken = true;
+                        }
+                    }
+                });
+            });
+        }
+
+        return is_taken;
+    }
+
+};
+
+module.exports = internalHost;

+ 68 - 102
src/backend/internal/proxy-host.js

@@ -3,6 +3,7 @@
 const _              = require('lodash');
 const error          = require('../lib/error');
 const proxyHostModel = require('../models/proxy_host');
+const internalHost   = require('./host');
 
 function omissions () {
     return ['is_deleted'];
@@ -16,60 +17,39 @@ const internalProxyHost = {
      * @returns {Promise}
      */
     create: (access, data) => {
-        let auth = data.auth || null;
-        delete data.auth;
-
-        data.avatar = data.avatar || '';
-        data.roles  = data.roles || [];
-
-        if (typeof data.is_disabled !== 'undefined') {
-            data.is_disabled = data.is_disabled ? 1 : 0;
-        }
-
         return access.can('proxy_hosts:create', data)
+            .then(access_data => {
+                // Get a list of the domain names and check each of them against existing records
+                let domain_name_check_promises = [];
+
+                data.domain_names.map(function (domain_name) {
+                    domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name));
+                });
+
+                return Promise.all(domain_name_check_promises)
+                    .then(check_results => {
+                        check_results.map(function (result) {
+                            if (result.is_taken) {
+                                throw new error.ValidationError(result.hostname + ' is already in use');
+                            }
+                        });
+                    });
+            })
             .then(() => {
-                data.avatar = gravatar.url(data.email, {default: 'mm'});
+                // At this point the domains should have been checked
+                data.owner_user_id = access.token.get('attrs').id;
+
+                if (typeof data.meta === 'undefined') {
+                    data.meta = {};
+                }
 
-                return userModel
+                return proxyHostModel
                     .query()
                     .omit(omissions())
                     .insertAndFetch(data);
             })
-            .then(user => {
-                if (auth) {
-                    return authModel
-                        .query()
-                        .insert({
-                            user_id: user.id,
-                            type:    auth.type,
-                            secret:  auth.secret,
-                            meta:    {}
-                        })
-                        .then(() => {
-                            return user;
-                        });
-                } else {
-                    return user;
-                }
-            })
-            .then(user => {
-                // Create permissions row as well
-                let is_admin = data.roles.indexOf('admin') !== -1;
-
-                return userPermissionModel
-                    .query()
-                    .insert({
-                        user_id:           user.id,
-                        visibility:        is_admin ? 'all' : 'user',
-                        proxy_hosts:       'manage',
-                        redirection_hosts: 'manage',
-                        dead_hosts:        'manage',
-                        streams:           'manage',
-                        access_lists:      'manage'
-                    })
-                    .then(() => {
-                        return internalProxyHost.get(access, {id: user.id, expand: ['permissions']});
-                    });
+            .then(row => {
+                return _.omit(row, omissions());
             });
     },
 
@@ -82,63 +62,49 @@ const internalProxyHost = {
      * @return {Promise}
      */
     update: (access, data) => {
-        if (typeof data.is_disabled !== 'undefined') {
-            data.is_disabled = data.is_disabled ? 1 : 0;
-        }
-
         return access.can('proxy_hosts:update', data.id)
-            .then(() => {
-
-                // Make sure that the user being updated doesn't change their email to another user that is already using it
-                // 1. get user we want to update
-                return internalProxyHost.get(access, {id: data.id})
-                    .then(user => {
-
-                        // 2. if email is to be changed, find other users with that email
-                        if (typeof data.email !== 'undefined') {
-                            data.email = data.email.toLowerCase().trim();
-
-                            if (user.email !== data.email) {
-                                return internalProxyHost.isEmailAvailable(data.email, data.id)
-                                    .then(available => {
-                                        if (!available) {
-                                            throw new error.ValidationError('Email address already in use - ' + data.email);
-                                        }
-
-                                        return user;
-                                    });
-                            }
-                        }
+            .then(access_data => {
+                // Get a list of the domain names and check each of them against existing records
+                let domain_name_check_promises = [];
 
-                        // No change to email:
-                        return user;
+                if (typeof data.domain_names !== 'undefined') {
+                    data.domain_names.map(function (domain_name) {
+                        domain_name_check_promises.push(internalHost.isHostnameTaken(domain_name, 'proxy', data.id));
                     });
+
+                    return Promise.all(domain_name_check_promises)
+                        .then(check_results => {
+                            check_results.map(function (result) {
+                                if (result.is_taken) {
+                                    throw new error.ValidationError(result.hostname + ' is already in use');
+                                }
+                            });
+                        });
+                }
             })
-            .then(user => {
-                if (user.id !== data.id) {
+            .then(() => {
+                return internalProxyHost.get(access, {id: data.id});
+            })
+            .then(row => {
+                if (row.id !== data.id) {
                     // Sanity check that something crazy hasn't happened
-                    throw new error.InternalValidationError('User could not be updated, IDs do not match: ' + user.id + ' !== ' + data.id);
+                    throw new error.InternalValidationError('Proxy Host could not be updated, IDs do not match: ' + row.id + ' !== ' + data.id);
                 }
 
-                data.avatar = gravatar.url(data.email || user.email, {default: 'mm'});
-
-                return userModel
+                return proxyHostModel
                     .query()
                     .omit(omissions())
-                    .patchAndFetchById(user.id, data)
-                    .then(saved_user => {
-                        return _.omit(saved_user, omissions());
+                    .patchAndFetchById(row.id, data)
+                    .then(saved_row => {
+                        return _.omit(saved_row, omissions());
                     });
-            })
-            .then(() => {
-                return internalProxyHost.get(access, {id: data.id});
             });
     },
 
     /**
      * @param  {Access}   access
-     * @param  {Object}   [data]
-     * @param  {Integer}  [data.id]          Defaults to the token user
+     * @param  {Object}   data
+     * @param  {Integer}  data.id
      * @param  {Array}    [data.expand]
      * @param  {Array}    [data.omit]
      * @return {Promise}
@@ -153,14 +119,18 @@ const internalProxyHost = {
         }
 
         return access.can('proxy_hosts:get', data.id)
-            .then(() => {
-                let query = userModel
+            .then(access_data => {
+                let query = proxyHostModel
                     .query()
                     .where('is_deleted', 0)
                     .andWhere('id', data.id)
                     .allowEager('[permissions]')
                     .first();
 
+                if (access_data.permission_visibility !== 'all') {
+                    query.andWhere('owner_user_id', access.token.get('attrs').id);
+                }
+
                 // Custom omissions
                 if (typeof data.omit !== 'undefined' && data.omit !== null) {
                     query.omit(data.omit);
@@ -193,19 +163,14 @@ const internalProxyHost = {
             .then(() => {
                 return internalProxyHost.get(access, {id: data.id});
             })
-            .then(user => {
-                if (!user) {
+            .then(row => {
+                if (!row) {
                     throw new error.ItemNotFoundError(data.id);
                 }
 
-                // Make sure user can't delete themselves
-                if (user.id === access.token.get('attrs').id) {
-                    throw new error.PermissionError('You cannot delete yourself.');
-                }
-
-                return userModel
+                return proxyHostModel
                     .query()
-                    .where('id', user.id)
+                    .where('id', row.id)
                     .patch({
                         is_deleted: 1
                     });
@@ -231,7 +196,8 @@ const internalProxyHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .orderBy('domain_name', 'ASC');
+                    .allowEager('[owner,access_list]')
+                    .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
                     query.andWhere('owner_user_id', access.token.get('attrs').id);
@@ -240,7 +206,7 @@ const internalProxyHost = {
                 // Query is used for searching
                 if (typeof search_query === 'string') {
                     query.where(function () {
-                        this.where('domain_name', 'like', '%' + search_query + '%');
+                        this.where('domain_names', 'like', '%' + search_query + '%');
                     });
                 }
 

+ 2 - 2
src/backend/internal/redirection-host.js

@@ -26,7 +26,7 @@ const internalProxyHost = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
-                    .orderBy('domain_name', 'ASC');
+                    .orderBy('domain_names', 'ASC');
 
                 if (access_data.permission_visibility !== 'all') {
                     query.andWhere('owner_user_id', access.token.get('attrs').id);
@@ -35,7 +35,7 @@ const internalProxyHost = {
                 // Query is used for searching
                 if (typeof search_query === 'string') {
                     query.where(function () {
-                        this.where('domain_name', 'like', '%' + search_query + '%');
+                        this.where('domain_names', 'like', '%' + search_query + '%');
                     });
                 }
 

+ 1 - 0
src/backend/internal/user.js

@@ -290,6 +290,7 @@ const internalUser = {
                     .where('is_deleted', 0)
                     .groupBy('id')
                     .omit(['is_deleted'])
+                    .allowEager('[permissions]')
                     .orderBy('name', 'ASC');
 
                 // Query is used for searching

+ 2 - 2
src/backend/lib/access.js

@@ -301,8 +301,8 @@ module.exports = function (token_string) {
                             });
                     })
                     .catch(err => {
-                        //logger.error(err.message);
-                        //logger.error(err.errors);
+                        logger.error(err.message);
+                        logger.error(err.errors);
 
                         throw new error.PermissionError('Permission Denied', err);
                     });

+ 23 - 0
src/backend/lib/access/proxy_hosts-create.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_proxy_hosts", "roles"],
+      "properties": {
+        "permission_proxy_hosts": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/proxy_hosts-delete.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_proxy_hosts", "roles"],
+      "properties": {
+        "permission_proxy_hosts": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/proxy_hosts-get.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_proxy_hosts", "roles"],
+      "properties": {
+        "permission_proxy_hosts": {
+          "$ref": "perms#/definitions/view"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 23 - 0
src/backend/lib/access/proxy_hosts-update.json

@@ -0,0 +1,23 @@
+{
+  "anyOf": [
+    {
+      "$ref": "roles#/definitions/admin"
+    },
+    {
+      "type": "object",
+      "required": ["permission_proxy_hosts", "roles"],
+      "properties": {
+        "permission_proxy_hosts": {
+          "$ref": "perms#/definitions/manage"
+        },
+        "roles": {
+          "type": "array",
+          "items": {
+            "type": "string",
+            "enum": ["user"]
+          }
+        }
+      }
+    }
+  ]
+}

+ 3 - 3
src/backend/migrations/20180618015850_initial.js

@@ -67,7 +67,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.dateTime('modified_on').notNull();
                 table.integer('owner_user_id').notNull().unsigned();
                 table.integer('is_deleted').notNull().unsigned().defaultTo(0);
-                table.string('domain_name').notNull();
+                table.json('domain_names').notNull();
                 table.string('forward_ip').notNull();
                 table.integer('forward_port').notNull().unsigned();
                 table.integer('access_list_id').notNull().unsigned().defaultTo(0);
@@ -88,7 +88,7 @@ exports.up = function (knex/*, Promise*/) {
                 table.dateTime('modified_on').notNull();
                 table.integer('owner_user_id').notNull().unsigned();
                 table.integer('is_deleted').notNull().unsigned().defaultTo(0);
-                table.string('domain_name').notNull();
+                table.json('domain_names').notNull();
                 table.string('forward_domain_name').notNull();
                 table.integer('preserve_path').notNull().unsigned().defaultTo(0);
                 table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
@@ -106,7 +106,7 @@ exports.up = function (knex/*, Promise*/) {
                     table.dateTime('modified_on').notNull();
                     table.integer('owner_user_id').notNull().unsigned();
                     table.integer('is_deleted').notNull().unsigned().defaultTo(0);
-                    table.string('domain_name').notNull();
+                    table.json('domain_names').notNull();
                     table.integer('ssl_enabled').notNull().unsigned().defaultTo(0);
                     table.string('ssl_provider').notNull().defaultTo('');
                     table.json('meta').notNull();

+ 52 - 0
src/backend/models/access_list.js

@@ -0,0 +1,52 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+const User  = require('./user');
+
+Model.knex(db);
+
+class AccessList 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 'AccessList';
+    }
+
+    static get tableName () {
+        return 'access_list';
+    }
+
+    static get jsonAttributes () {
+        return ['meta'];
+    }
+
+    static get relationMappings () {
+        return {
+            owner: {
+                relation:   Model.HasOneRelation,
+                modelClass: User,
+                join:       {
+                    from: 'access_list.owner_user_id',
+                    to:   'user.id'
+                },
+                modify:     function (qb) {
+                    qb.where('user.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = AccessList;

+ 51 - 0
src/backend/models/access_list_auth.js

@@ -0,0 +1,51 @@
+// Objection Docs:
+// http://vincit.github.io/objection.js/
+
+'use strict';
+
+const db    = require('../db');
+const Model = require('objection').Model;
+
+Model.knex(db);
+
+class AccessListAuth 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 'AccessListAuth';
+    }
+
+    static get tableName () {
+        return 'access_list_auth';
+    }
+
+    static get jsonAttributes () {
+        return ['meta'];
+    }
+
+    static get relationMappings () {
+        return {
+            access_list: {
+                relation:   Model.HasOneRelation,
+                modelClass: './access_list',
+                join:       {
+                    from: 'access_list_auth.access_list_id',
+                    to:   'access_list.id'
+                },
+                modify:     function (qb) {
+                    qb.where('access_list.is_deleted', 0);
+                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'access_list_id']);
+                }
+            }
+        };
+    }
+}
+
+module.exports = AccessListAuth;

+ 5 - 1
src/backend/models/dead_host.js

@@ -27,6 +27,10 @@ class DeadHost extends Model {
         return 'dead_host';
     }
 
+    static get jsonAttributes () {
+        return ['domain_names', 'meta'];
+    }
+
     static get relationMappings () {
         return {
             owner: {
@@ -38,7 +42,7 @@ class DeadHost extends Model {
                 },
                 modify:     function (qb) {
                     qb.where('user.is_deleted', 0);
-                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
             }
         };

+ 26 - 5
src/backend/models/proxy_host.js

@@ -3,9 +3,10 @@
 
 'use strict';
 
-const db    = require('../db');
-const Model = require('objection').Model;
-const User  = require('./user');
+const db         = require('../db');
+const Model      = require('objection').Model;
+const User       = require('./user');
+const AccessList = require('./access_list');
 
 Model.knex(db);
 
@@ -13,10 +14,14 @@ class ProxyHost extends Model {
     $beforeInsert () {
         this.created_on  = Model.raw('NOW()');
         this.modified_on = Model.raw('NOW()');
+        this.domain_names.sort();
     }
 
     $beforeUpdate () {
         this.modified_on = Model.raw('NOW()');
+        if (typeof this.domain_names !== 'undefined') {
+            this.domain_names.sort();
+        }
     }
 
     static get name () {
@@ -27,9 +32,13 @@ class ProxyHost extends Model {
         return 'proxy_host';
     }
 
+    static get jsonAttributes () {
+        return ['domain_names', 'meta'];
+    }
+
     static get relationMappings () {
         return {
-            owner: {
+            owner:       {
                 relation:   Model.HasOneRelation,
                 modelClass: User,
                 join:       {
@@ -38,7 +47,19 @@ class ProxyHost extends Model {
                 },
                 modify:     function (qb) {
                     qb.where('user.is_deleted', 0);
-                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                }
+            },
+            access_list: {
+                relation:   Model.HasOneRelation,
+                modelClass: AccessList,
+                join:       {
+                    from: 'proxy_host.access_list_id',
+                    to:   'access_list.id'
+                },
+                modify:     function (qb) {
+                    qb.where('access_list.is_deleted', 0);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted']);
                 }
             }
         };

+ 5 - 1
src/backend/models/redirection_host.js

@@ -27,6 +27,10 @@ class RedirectionHost extends Model {
         return 'redirection_host';
     }
 
+    static get jsonAttributes () {
+        return ['domain_names', 'meta'];
+    }
+
     static get relationMappings () {
         return {
             owner: {
@@ -38,7 +42,7 @@ class RedirectionHost extends Model {
                 },
                 modify:     function (qb) {
                     qb.where('user.is_deleted', 0);
-                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
             }
         };

+ 5 - 1
src/backend/models/stream.js

@@ -27,6 +27,10 @@ class Stream extends Model {
         return 'stream';
     }
 
+    static get jsonAttributes () {
+        return ['meta'];
+    }
+
     static get relationMappings () {
         return {
             owner: {
@@ -38,7 +42,7 @@ class Stream extends Model {
                 },
                 modify:     function (qb) {
                     qb.where('user.is_deleted', 0);
-                    qb.omit(['created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
+                    qb.omit(['id', 'created_on', 'modified_on', 'is_deleted', 'email', 'roles']);
                 }
             }
         };

+ 3 - 3
src/backend/routes/api/nginx/proxy_hosts.js

@@ -104,7 +104,7 @@ router
         })
             .then(data => {
                 return internalProxyHost.get(res.locals.access, {
-                    id:     data.host_id,
+                    id:     parseInt(data.host_id, 10),
                     expand: data.expand
                 });
             })
@@ -123,7 +123,7 @@ router
     .put((req, res, next) => {
         apiValidator({$ref: 'endpoints/proxy-hosts#/links/2/schema'}, req.body)
             .then(payload => {
-                payload.id = req.params.host_id;
+                payload.id = parseInt(req.params.host_id, 10);
                 return internalProxyHost.update(res.locals.access, payload);
             })
             .then(result => {
@@ -139,7 +139,7 @@ router
      * Update and existing proxy-host
      */
     .delete((req, res, next) => {
-        internalProxyHost.delete(res.locals.access, {id: req.params.host_id})
+        internalProxyHost.delete(res.locals.access, {id: parseInt(req.params.host_id, 10)})
             .then(result => {
                 res.status(200)
                     .send(result);

+ 25 - 0
src/backend/schema/definitions.json

@@ -134,6 +134,31 @@
       "type": "string",
       "minLength": 8,
       "maxLength": 255
+    },
+    "domain_names": {
+      "description": "Domain Names separated by a comma",
+      "example": "*.jc21.com,blog.jc21.com",
+      "type": "array",
+      "maxItems": 15,
+      "uniqueItems": true,
+      "items": {
+        "type": "string",
+        "pattern": "^(?:\\*\\.)?(?:[^.*]+\\.?)+[^.]$"
+      }
+    },
+    "ssl_enabled": {
+      "description": "Is SSL Enabled",
+      "example": true,
+      "type": "boolean"
+    },
+    "ssl_forced": {
+      "description": "Is SSL Forced",
+      "example": false,
+      "type": "boolean"
+    },
+    "ssl_provider": {
+      "type": "string",
+      "pattern": "^(letsencrypt|other)$"
     }
   }
 }

+ 105 - 101
src/backend/schema/endpoints/proxy-hosts.json

@@ -1,7 +1,7 @@
 {
   "$schema": "http://json-schema.org/draft-07/schema#",
   "$id": "endpoints/proxy-hosts",
-  "title": "Users",
+  "title": "Proxy Hosts",
   "description": "Endpoints relating to Proxy Hosts",
   "stability": "stable",
   "type": "object",
@@ -15,49 +15,78 @@
     "modified_on": {
       "$ref": "../definitions.json#/definitions/modified_on"
     },
-    "name": {
-      "description": "Name",
-      "example": "Jamie Curnow",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 100
+    "domain_names": {
+      "$ref": "../definitions.json#/definitions/domain_names"
     },
-    "nickname": {
-      "description": "Nickname",
-      "example": "Jamie",
+    "forward_ip": {
       "type": "string",
-      "minLength": 2,
-      "maxLength": 50
+      "format": "ipv4"
+    },
+    "forward_port": {
+      "type": "integer",
+      "minimum": 1,
+      "maximum": 65535
+    },
+    "ssl_enabled": {
+      "$ref": "../definitions.json#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "../definitions.json#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "../definitions.json#/definitions/ssl_provider"
+    },
+    "meta": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "letsencrypt_email": {
+          "type": "string",
+          "format": "email"
+        },
+        "letsencrypt_agree": {
+          "type": "boolean"
+        }
+      }
+    }
+  },
+  "properties": {
+    "id": {
+      "$ref": "#/definitions/id"
+    },
+    "created_on": {
+      "$ref": "#/definitions/created_on"
     },
-    "email": {
-      "$ref": "../definitions.json#/definitions/email"
+    "modified_on": {
+      "$ref": "#/definitions/modified_on"
     },
-    "avatar": {
-      "description": "Avatar",
-      "example": "http://somewhere.jpg",
-      "type": "string",
-      "minLength": 2,
-      "maxLength": 150,
-      "readOnly": true
-    },
-    "roles": {
-      "description": "Roles",
-      "example": [
-        "admin"
-      ],
-      "type": "array"
-    },
-    "is_disabled": {
-      "description": "Is Disabled",
-      "example": false,
-      "type": "boolean"
+    "domain_names": {
+      "$ref": "#/definitions/domain_names"
+    },
+    "forward_ip": {
+      "$ref": "#/definitions/forward_ip"
+    },
+    "forward_port": {
+      "$ref": "#/definitions/forward_port"
+    },
+    "ssl_enabled": {
+      "$ref": "#/definitions/ssl_enabled"
+    },
+    "ssl_forced": {
+      "$ref": "#/definitions/ssl_forced"
+    },
+    "ssl_provider": {
+      "$ref": "#/definitions/ssl_provider"
+    },
+    "meta": {
+      "$ref": "#/definitions/meta"
     }
   },
   "links": [
     {
       "title": "List",
-      "description": "Returns a list of Users",
-      "href": "/users",
+      "description": "Returns a list of Proxy Hosts",
+      "href": "/nginx/proxy-hosts",
       "access": "private",
       "method": "GET",
       "rel": "self",
@@ -73,8 +102,8 @@
     },
     {
       "title": "Create",
-      "description": "Creates a new User",
-      "href": "/users",
+      "description": "Creates a new Proxy Host",
+      "href": "/nginx/proxy-hosts",
       "access": "private",
       "method": "POST",
       "rel": "create",
@@ -84,33 +113,31 @@
       "schema": {
         "type": "object",
         "required": [
-          "name",
-          "nickname",
-          "email"
+          "domain_names",
+          "forward_ip",
+          "forward_port"
         ],
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "forward_ip": {
+            "$ref": "#/definitions/forward_ip"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "forward_port": {
+            "$ref": "#/definitions/forward_port"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "ssl_enabled": {
+            "$ref": "#/definitions/ssl_enabled"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "ssl_forced": {
+            "$ref": "#/definitions/ssl_forced"
           },
-          "auth": {
-            "type": "object",
-            "description": "Auth Credentials",
-            "example": {
-              "type": "password",
-              "secret": "bigredhorsebanana"
-            }
+          "ssl_provider": {
+            "$ref": "#/definitions/ssl_provider"
+          },
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -122,8 +149,8 @@
     },
     {
       "title": "Update",
-      "description": "Updates a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Updates a existing Proxy Host",
+      "href": "/nginx/proxy-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "PUT",
       "rel": "update",
@@ -133,20 +160,26 @@
       "schema": {
         "type": "object",
         "properties": {
-          "name": {
-            "$ref": "#/definitions/name"
+          "domain_names": {
+            "$ref": "#/definitions/domain_names"
+          },
+          "forward_ip": {
+            "$ref": "#/definitions/forward_ip"
+          },
+          "forward_port": {
+            "$ref": "#/definitions/forward_port"
           },
-          "nickname": {
-            "$ref": "#/definitions/nickname"
+          "ssl_enabled": {
+            "$ref": "#/definitions/ssl_enabled"
           },
-          "email": {
-            "$ref": "#/definitions/email"
+          "ssl_forced": {
+            "$ref": "#/definitions/ssl_forced"
           },
-          "roles": {
-            "$ref": "#/definitions/roles"
+          "ssl_provider": {
+            "$ref": "#/definitions/ssl_provider"
           },
-          "is_disabled": {
-            "$ref": "#/definitions/is_disabled"
+          "meta": {
+            "$ref": "#/definitions/meta"
           }
         }
       },
@@ -158,8 +191,8 @@
     },
     {
       "title": "Delete",
-      "description": "Deletes a existing User",
-      "href": "/users/{definitions.identity.example}",
+      "description": "Deletes a existing Proxy Host",
+      "href": "/nginx/proxy-hosts/{definitions.identity.example}",
       "access": "private",
       "method": "DELETE",
       "rel": "delete",
@@ -170,34 +203,5 @@
         "type": "boolean"
       }
     }
-  ],
-  "properties": {
-    "id": {
-      "$ref": "#/definitions/id"
-    },
-    "created_on": {
-      "$ref": "#/definitions/created_on"
-    },
-    "modified_on": {
-      "$ref": "#/definitions/modified_on"
-    },
-    "name": {
-      "$ref": "#/definitions/name"
-    },
-    "nickname": {
-      "$ref": "#/definitions/nickname"
-    },
-    "email": {
-      "$ref": "#/definitions/email"
-    },
-    "avatar": {
-      "$ref": "#/definitions/avatar"
-    },
-    "roles": {
-      "$ref": "#/definitions/roles"
-    },
-    "is_disabled": {
-      "$ref": "#/definitions/is_disabled"
-    }
-  }
+  ]
 }

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

@@ -264,6 +264,25 @@ module.exports = {
              */
             create: function (data) {
                 return fetch('post', 'nginx/proxy-hosts', data);
+            },
+
+            /**
+             * @param   {Object}   data
+             * @param   {Integer}  data.id
+             * @returns {Promise}
+             */
+            update: function (data) {
+                let id = data.id;
+                delete data.id;
+                return fetch('put', 'nginx/proxy-hosts/' + id, data);
+            },
+
+            /**
+             * @param   {Integer}  id
+             * @returns {Promise}
+             */
+            delete: function (id) {
+                return fetch('delete', 'nginx/proxy-hosts/' + id);
             }
         },
 

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

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

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

@@ -1,6 +1,6 @@
 <div class="card">
     <div class="card-header">
-        <h3 class="card-title">Audit Log</h3>
+        <h3 class="card-title"><%- i18n('audit-log', 'title') %></h3>
     </div>
     <div class="card-body no-padding min-100">
         <div class="dimmer active">

+ 5 - 6
src/frontend/js/app/audit-log/main.js

@@ -1,9 +1,8 @@
 'use strict';
 
 const Mn            = require('backbone.marionette');
+const App           = require('../main');
 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');
@@ -25,7 +24,7 @@ module.exports = Mn.View.extend({
     onRender: function () {
         let view = this;
 
-        Api.AuditLog.getAll()
+        App.Api.AuditLog.getAll()
             .then(response => {
                 if (!view.isDestroyed() && response && response.length) {
                     view.showChildView('list_region', new ListView({
@@ -33,8 +32,8 @@ module.exports = Mn.View.extend({
                     }));
                 } 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.'
+                        title:    App.i18n('audit-log', 'empty'),
+                        subtitle: App.i18n('audit-log', 'empty-subtitle')
                     }));
                 }
             })
@@ -43,7 +42,7 @@ module.exports = Mn.View.extend({
                     code:    err.code,
                     message: err.message,
                     retry:   function () {
-                        Controller.showAuditLog();
+                        App.Controller.showAuditLog();
                     }
                 }));
 

+ 3 - 1
src/frontend/js/app/cache.js

@@ -3,7 +3,9 @@
 const UserModel = require('../models/user');
 
 let cache = {
-    User: new UserModel.Model()
+    User:    new UserModel.Model(),
+    locale:  'en',
+    version: null
 };
 
 module.exports = cache;

+ 13 - 0
src/frontend/js/app/controller.js

@@ -147,6 +147,19 @@ module.exports = {
         }
     },
 
+    /**
+     * Proxy Host Delete Confirm
+     *
+     * @param model
+     */
+    showNginxProxyDeleteConfirm: function (model) {
+        if (Cache.User.isAdmin() || Cache.User.canManage('proxy_hosts')) {
+            require(['./main', './nginx/proxy/delete'], function (App, View) {
+                App.UI.showModalDialog(new View({model: model}));
+            });
+        }
+    },
+
     /**
      * Nginx Redirection Hosts
      */

+ 5 - 5
src/frontend/js/app/dashboard/main.ejs

@@ -1,5 +1,5 @@
 <div class="page-header">
-    <h1 class="page-title">Hi <%- getUserName() %></h1>
+    <h1 class="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
 </div>
 
 <% if (columns) { %>
@@ -12,7 +12,7 @@
                       <i class="fe fe-zap"></i>
                     </span>
                 <div>
-                    <h4 class="m-0"><a href="/nginx/proxy"><%- getHostStat('proxy') %> <small>Proxy Hosts</small></a></h4>
+                    <h4 class="m-0"><a href="/nginx/proxy"><%- getHostStat('proxy') %> <small><%- i18n('proxy-hosts', 'title') %></small></a></h4>
                 </div>
             </div>
         </div>
@@ -27,7 +27,7 @@
                       <i class="fe fe-shuffle"></i>
                     </span>
                 <div>
-                    <h4 class="m-0"><a href="/nginx/redirection"><%- getHostStat('redirection') %> <small>Redirection Hosts</small></a></h4>
+                    <h4 class="m-0"><a href="/nginx/redirection"><%- getHostStat('redirection') %> <small><%- i18n('redirection-hosts', 'title') %></small></a></h4>
                 </div>
             </div>
         </div>
@@ -42,7 +42,7 @@
                       <i class="fe fe-radio"></i>
                     </span>
                 <div>
-                    <h4 class="m-0"><a href="/nginx/stream"><%- getHostStat('stream') %> <small> Streams</small></a></h4>
+                    <h4 class="m-0"><a href="/nginx/stream"><%- getHostStat('stream') %> <small> <%- i18n('streams', 'title') %></small></a></h4>
                 </div>
             </div>
         </div>
@@ -57,7 +57,7 @@
                       <i class="fe fe-zap-off"></i>
                     </span>
                 <div>
-                    <h4 class="m-0"><a href="/nginx/404"><%- getHostStat('dead') %> <small>404 Hosts</small></a></h4>
+                    <h4 class="m-0"><a href="/nginx/404"><%- getHostStat('dead') %> <small><%- i18n('dead-hosts', 'title') %></small></a></h4>
                 </div>
             </div>
         </div>

+ 1 - 1
src/frontend/js/app/error/main.ejs

@@ -3,5 +3,5 @@
 <%- message %>
 
 <% if (retry) { %>
-    <br><br><a href="#" class="btn btn-sm btn-warning retry">Try again</a>
+    <br><br><a href="#" class="btn btn-sm btn-warning retry"><%- i18n('str', 'try-again') %></a>
 <% } %>

+ 25 - 0
src/frontend/js/app/i18n.js

@@ -0,0 +1,25 @@
+'use strict';
+
+const Cache    = ('./cache');
+const messages = require('../i18n/messages.json');
+
+/**
+ * @param {String}  namespace
+ * @param {String}  key
+ * @param {Object}  [data]
+ */
+module.exports = function (namespace, key, data) {
+    let locale = Cache.locale;
+    // check that the locale exists
+    if (typeof messages[locale] === 'undefined') {
+        locale = 'en';
+    }
+
+    if (typeof messages[locale][namespace] !== 'undefined' && typeof messages[locale][namespace][key] !== 'undefined') {
+        return messages[locale][namespace][key](data);
+    } else if (locale !== 'en' && typeof messages['en'][namespace] !== 'undefined' && typeof messages['en'][namespace][key] !== 'undefined') {
+        return messages['en'][namespace][key](data);
+    }
+
+    return '(MISSING: ' + namespace + '/' + key + ')';
+};

+ 7 - 7
src/frontend/js/app/main.js

@@ -9,14 +9,15 @@ const Router     = require('./router');
 const Api        = require('./api');
 const Tokens     = require('./tokens');
 const UI         = require('./ui/main');
+const i18n       = require('./i18n');
 
 const App = Mn.Application.extend({
 
     Cache:      Cache,
     Api:        Api,
     UI:         null,
+    i18n:       i18n,
     Controller: Controller,
-    version:    null,
 
     region: {
         el:             '#app',
@@ -24,7 +25,7 @@ const App = Mn.Application.extend({
     },
 
     onStart: function (app, options) {
-        console.log('Welcome to Nginx Proxy Manager');
+        console.log(i18n('main', 'welcome'));
 
         // Check if token is coming through
         if (this.getParam('token')) {
@@ -34,12 +35,12 @@ const App = Mn.Application.extend({
         // Check if we are still logged in by refreshing the token
         Api.status()
             .then(result => {
-                this.version = [result.version.major, result.version.minor, result.version.revision].join('.');
+                Cache.version = [result.version.major, result.version.minor, result.version.revision].join('.');
             })
             .then(Api.Tokens.refresh)
             .then(this.bootstrap)
             .then(() => {
-                console.info('You are logged in');
+                console.info(i18n('main', 'logged-in', Cache.User.attributes));
                 this.bootstrapTimer();
                 this.refreshTokenTimer();
 
@@ -60,7 +61,6 @@ const App = Mn.Application.extend({
                 console.warn('Not logged in:', err.message);
                 Controller.showLogin();
             });
-
     },
 
     History: {
@@ -86,7 +86,7 @@ const App = Mn.Application.extend({
         let ErrorView = Mn.View.extend({
             tagName:  'section',
             id:       'error',
-            template: _.template('Error loading stuff. Please reload the app.')
+            template: _.template(i18n('main', 'unknown-error'))
         });
 
         this.getRegion().show(new ErrorView());
@@ -130,7 +130,7 @@ const App = Mn.Application.extend({
             Api.status()
                 .then(result => {
                     let version = [result.version.major, result.version.minor, result.version.revision].join('.');
-                    if (version !== this.version) {
+                    if (version !== Cache.version) {
                         document.location.reload();
                     }
                 })

+ 24 - 0
src/frontend/js/app/nginx/proxy/delete.ejs

@@ -0,0 +1,24 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('proxy-hosts', 'delete') %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body">
+        <form>
+            <div class="row">
+                <div class="col-sm-12 col-md-12">
+                    <%= i18n('proxy-hosts', 'delete-confirm', {domains: domain_names.join(', ')}) %>
+                    Are you sure you want to delete the Proxy host for: <strong><%- domain_names.join(', ') %></strong>?
+                    <% if (ssl_enabled) { %>
+                        <br><br>
+                        <%- i18n('proxy-hosts', 'delete-ssl') %>
+                    <% } %>
+                </div>
+            </div>
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-danger save"><%- i18n('str', 'sure') %></button>
+    </div>
+</div>

+ 36 - 0
src/frontend/js/app/nginx/proxy/delete.js

@@ -0,0 +1,36 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const App      = require('../../main');
+const template = require('./delete.ejs');
+
+require('jquery-serializejson');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'modal-dialog',
+
+    ui: {
+        form:    'form',
+        buttons: '.modal-footer button',
+        cancel:  'button.cancel',
+        save:    'button.save'
+    },
+
+    events: {
+
+        'click @ui.save': function (e) {
+            e.preventDefault();
+
+            App.Api.Nginx.ProxyHosts.delete(this.model.get('id'))
+                .then(() => {
+                    App.Controller.showNginxProxy();
+                    App.UI.closeModal();
+                })
+                .catch(err => {
+                    alert(err.message);
+                    this.ui.buttons.prop('disabled', false).removeClass('btn-disabled');
+                });
+        }
+    }
+});

+ 23 - 22
src/frontend/js/app/nginx/proxy/form.ejs

@@ -1,34 +1,35 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title"><% if (typeof id !== 'undefined') { %>Edit<% } else { %>New<% } %> Proxy Host</h5>
+        <h5 class="modal-title"><%- i18n('proxy-hosts', 'form-title', {id: id}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body has-tabs">
         <form>
             <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>
+                <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> <%- i18n('all-hosts', '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> <%- i18n('str', '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">
+                                <label class="form-label"><%- i18n('all-hosts', 'domain-names') %> <span class="form-required">*</span></label>
+                                <input type="text" name="domain_names" class="form-control" id="input-domains" value="<%- domain_names.join(',') %>" required>
                             </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>
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-ip') %><span class="form-required">*</span></label>
+                                <input type="text" name="forward_ip" class="form-control text-monospace" placeholder="000.000.000.000" value="<%- forward_ip %>" 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>
+                                <label class="form-label"><%- i18n('proxy-hosts', 'forward-port') %> <span class="form-required">*</span></label>
+                                <input name="forward_port" type="number" class="form-control text-monospace" placeholder="80" value="<%- forward_port %>" required>
                             </div>
                         </div>
                     </div>
@@ -42,7 +43,7 @@
                                 <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>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', 'enable-ssl') %></span>
                                 </label>
                             </div>
                         </div>
@@ -51,21 +52,21 @@
                                 <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>
+                                    <span class="custom-switch-description"><%- i18n('all-hosts', '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>
+                                <label class="form-label"><%- i18n('all-hosts', 'cert-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>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'letsencrypt') %></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>
+                                        <span class="selectgroup-button"><%- i18n('ssl', 'other') %></span>
                                     </label>
                                 </div>
                             </div>
@@ -74,7 +75,7 @@
                         <!-- 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>
+                                <label class="form-label"><%- i18n('ssl', 'letsencrypt-email') %> <span class="form-required">*</span></label>
                                 <input name="meta[letsencrypt_email]" type="email" class="form-control" placeholder="" value="<%- getLetsencryptEmail() %>" required>
                             </div>
                         </div>
@@ -83,7 +84,7 @@
                                 <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>
+                                    <span class="custom-switch-description"><%= i18n('ssl', 'letsencrypt-agree', {url: 'https://letsencrypt.org/repository/'}) %> <span class="form-required">*</span></span>
                                 </label>
                             </div>
                         </div>
@@ -91,19 +92,19 @@
                         <!-- Other -->
                         <div class="col-sm-12 col-md-12 other-ssl">
                             <div class="form-group">
-                                <div class="form-label">Certificate</div>
+                                <div class="form-label"><%- i18n('all-hosts', 'other-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>
+                                    <label class="custom-file-label"><%- i18n('str', '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="form-label"><%- i18n('all-hosts', 'other-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>
+                                    <label class="custom-file-label"><%- i18n('str', 'choose-file') %></label>
                                 </div>
                             </div>
                         </div>
@@ -115,7 +116,7 @@
         </form>
     </div>
     <div class="modal-footer">
-        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal">Cancel</button>
-        <button type="button" class="btn btn-teal save">Save</button>
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
     </div>
 </div>

+ 24 - 13
src/frontend/js/app/nginx/proxy/form.js

@@ -2,15 +2,13 @@
 
 const _              = require('underscore');
 const Mn             = require('backbone.marionette');
-const template       = require('./form.ejs');
-const Controller     = require('../../controller');
-const Cache          = require('../../cache');
-const Api            = require('../../api');
 const App            = require('../../main');
 const ProxyHostModel = require('../../../models/proxy-host');
+const template       = require('./form.ejs');
 
 require('jquery-serializejson');
 require('jquery-mask-plugin');
+require('selectize');
 
 module.exports = Mn.View.extend({
     template:  template,
@@ -18,7 +16,7 @@ module.exports = Mn.View.extend({
 
     ui: {
         form:         'form',
-        domain_name:  'input[name="domain_name"]',
+        domain_names: 'input[name="domain_names"]',
         forward_ip:   'input[name="forward_ip"]',
         buttons:      '.modal-footer button',
         cancel:       'button.cancel',
@@ -73,13 +71,17 @@ module.exports = Mn.View.extend({
                 data[idx] = item;
             });
 
+            if (typeof data.domain_names === 'string' && data.domain_names) {
+                data.domain_names = data.domain_names.split(',');
+            }
+
             // Process
             this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
-            let method = Api.Nginx.ProxyHosts.create;
+            let method = App.Api.Nginx.ProxyHosts.create;
 
             if (this.model.get('id')) {
                 // edit
-                method  = Api.Nginx.ProxyHosts.update;
+                method  = App.Api.Nginx.ProxyHosts.update;
                 data.id = this.model.get('id');
             }
 
@@ -87,8 +89,8 @@ module.exports = Mn.View.extend({
                 .then(result => {
                     view.model.set(result);
                     App.UI.closeModal(function () {
-                        if (method === Api.Nginx.ProxyHosts.create) {
-                            Controller.showNginxProxy();
+                        if (method === App.Api.Nginx.ProxyHosts.create) {
+                            App.Controller.showNginxProxy();
                         }
                     });
                 })
@@ -101,7 +103,7 @@ module.exports = Mn.View.extend({
 
     templateContext: {
         getLetsencryptEmail: function () {
-            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : Cache.User.get('email');
+            return typeof this.meta.letsencrypt_email !== 'undefined' ? this.meta.letsencrypt_email : App.Cache.User.get('email');
         },
 
         getLetsencryptAgree: function () {
@@ -118,9 +120,18 @@ module.exports = Mn.View.extend({
         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');
-        };
+        this.ui.domain_names.selectize({
+            delimiter:    ',',
+            persist:      false,
+            maxOptions:   15,
+            create:       function (input) {
+                return {
+                    value: input,
+                    text:  input
+                };
+            },
+            createFilter: /^(?:\*\.)?(?:[^.*]+\.?)+[^.]$/
+        });
     },
 
     initialize: function (options) {

+ 21 - 13
src/frontend/js/app/nginx/proxy/list/item.ejs

@@ -1,32 +1,40 @@
 <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 class="avatar d-block" style="background-image: url(<%- owner.avatar || '/images/default-avatar.jpg' %>)" title="Owned by <%- owner.name %>">
+        <span class="avatar-status <%- owner.is_disabled ? 'bg-red' : 'bg-green' %>"></span>
     </div>
 </td>
 <td>
-    <div><%- name %></div>
+    <div>
+        <% domain_names.map(function(host) {
+            %>
+            <span class="tag"><%- host %></span>
+            <%
+        });
+        %>
+    </div>
     <div class="small text-muted">
-        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+        <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %>
     </div>
 </td>
 <td>
-    <div><%- email %></div>
+    <div class="text-monospace"><%- forward_ip %>:<%- forward_port %></div>
+</td>
+<td>
+    <div><%- ssl_enabled && ssl_provider ? i18n('ssl', ssl_provider) : i18n('ssl', 'none') %></div>
 </td>
 <td>
-    <div><%- roles.join(', ') %></div>
+    <div><%- access_list_id ? access_list.name : i18n('str', 'public') %></div>
 </td>
+<% if (canManage) { %>
 <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>
+            <a href="#" class="edit dropdown-item"><i class="dropdown-icon fe fe-edit"></i> <%- i18n('str', 'edit') %></a>
+            <a href="#" class="logs dropdown-item"><i class="dropdown-icon fe fe-book"></i> <%- i18n('str', 'logs') %></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>
-            <% } %>
+            <a href="#" class="delete dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('str', 'delete') %></a>
         </div>
     </div>
 </td>
+<% } %>

+ 8 - 45
src/frontend/js/app/nginx/proxy/list/item.js

@@ -1,69 +1,32 @@
 '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');
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+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'
+        edit:   'a.edit',
+        delete: 'a.delete'
     },
 
     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);
+            App.Controller.showNginxProxyForm(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');
-                    });
-            }
+            App.Controller.showNginxProxyDeleteConfirm(this.model);
         }
     },
 
     templateContext: {
-        isSelf: function () {
-            return Cache.User.get('id') === this.id;
-        }
+        canManage: App.Cache.User.canManage('proxy_hosts')
     },
 
     initialize: function () {

+ 6 - 3
src/frontend/js/app/nginx/proxy/list/main.ejs

@@ -1,9 +1,12 @@
 <thead>
     <th width="30">&nbsp;</th>
-    <th>Name</th>
-    <th>Email</th>
-    <th>Roles</th>
+    <th><%- i18n('str', 'source') %></th>
+    <th><%- i18n('str', 'destination') %></th>
+    <th><%- i18n('str', 'ssl') %></th>
+    <th><%- i18n('str', 'access') %></th>
+    <% if (canManage) { %>
     <th>&nbsp;</th>
+    <% } %>
 </thead>
 <tbody>
     <!-- items -->

+ 8 - 3
src/frontend/js/app/nginx/proxy/list/main.js

@@ -1,8 +1,9 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const ItemView   = require('./item');
-const template   = require('./main.ejs');
+const Mn       = require('backbone.marionette');
+const App      = require('../../../main');
+const ItemView = require('./item');
+const template = require('./main.ejs');
 
 const TableBody = Mn.CollectionView.extend({
     tagName:   'tbody',
@@ -21,6 +22,10 @@ module.exports = Mn.View.extend({
         }
     },
 
+    templateContext: {
+        canManage: App.Cache.User.canManage('proxy_hosts')
+    },
+
     onRender: function () {
         this.showChildView('body', new TableBody({
             collection: this.collection

+ 2 - 2
src/frontend/js/app/nginx/proxy/main.ejs

@@ -1,10 +1,10 @@
 <div class="card">
     <div class="card-status bg-success"></div>
     <div class="card-header">
-        <h3 class="card-title">Proxy Hosts</h3>
+        <h3 class="card-title"><%- i18n('proxy-hosts', 'title') %></h3>
         <div class="card-options">
             <% if (showAddButton) { %>
-            <a href="#" class="btn btn-outline-success btn-sm ml-2 add-item">Add Proxy Host</a>
+            <a href="#" class="btn btn-outline-success btn-sm ml-2 add-item"><%- i18n('proxy-hosts', 'add') %></a>
             <% } %>
         </div>
     </div>

+ 11 - 13
src/frontend/js/app/nginx/proxy/main.js

@@ -1,14 +1,12 @@
 'use strict';
 
 const Mn             = require('backbone.marionette');
+const App            = require('../../main');
 const ProxyHostModel = require('../../../models/proxy-host');
-const Api            = require('../../api');
-const Cache          = require('../../cache');
-const Controller     = require('../../controller');
 const ListView       = require('./list/main');
 const ErrorView      = require('../../error/main');
-const template       = require('./main.ejs');
 const EmptyView      = require('../../empty/main');
+const template       = require('./main.ejs');
 
 module.exports = Mn.View.extend({
     id:       'nginx-proxy',
@@ -27,18 +25,18 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.add': function (e) {
             e.preventDefault();
-            Controller.showNginxProxyForm();
+            App.Controller.showNginxProxyForm();
         }
     },
 
     templateContext: {
-        showAddButton: Cache.User.canManage('proxy_hosts')
+        showAddButton: App.Cache.User.canManage('proxy_hosts')
     },
 
     onRender: function () {
         let view = this;
 
-        Api.Nginx.ProxyHosts.getAll()
+        App.Api.Nginx.ProxyHosts.getAll(['owner', 'access_list'])
             .then(response => {
                 if (!view.isDestroyed()) {
                     if (response && response.length) {
@@ -46,16 +44,16 @@ module.exports = Mn.View.extend({
                             collection: new ProxyHostModel.Collection(response)
                         }));
                     } else {
-                        let manage = Cache.User.canManage('proxy_hosts');
+                        let manage = App.Cache.User.canManage('proxy_hosts');
 
                         view.showChildView('list_region', new EmptyView({
-                            title:      'There are no Proxy Hosts',
-                            subtitle:   manage ? 'Why don\'t you create one?' : 'And you don\'t have permission to create one.',
-                            link:       manage ? 'Add Proxy Host' : null,
+                            title:      App.i18n('proxy-hosts', 'empty'),
+                            subtitle:   App.i18n('all-hosts', 'empty-subtitle', {manage: manage}),
+                            link:       manage ? App.i18n('proxy-hosts', 'add') : null,
                             btn_color:  'success',
                             permission: 'proxy_hosts',
                             action:     function () {
-                                Controller.showNginxProxyForm();
+                                App.Controller.showNginxProxyForm();
                             }
                         }));
                     }
@@ -66,7 +64,7 @@ module.exports = Mn.View.extend({
                     code:    err.code,
                     message: err.message,
                     retry:   function () {
-                        Controller.showNginxProxy();
+                        App.Controller.showNginxProxy();
                     }
                 }));
 

+ 4 - 2
src/frontend/js/app/ui/footer/main.ejs

@@ -3,12 +3,14 @@
         <div class="row align-items-center">
             <div class="col-auto">
                 <ul class="list-inline list-inline-dots mb-0">
-                    <li class="list-inline-item"><a href="https://github.com/jc21/docker-registry-ui?utm_source=docker-registry-ui">Fork me on Github</a></li>
+                    <li class="list-inline-item"><a href="https://github.com/jc21/nginx-proxy-manager?utm_source=nginx-proxy-manager"><%- i18n('footer', 'fork-me') %></a></li>
                 </ul>
             </div>
         </div>
     </div>
     <div class="col-12 col-lg-auto mt-3 mt-lg-0 text-center">
-        v<%- getVersion() %> &copy; 2018 <a href="https://jc21.com?utm_source=nginx-proxy-manager" target="_blank">jc21.com</a>. Theme by <a href="https://tabler.github.io/?utm_source=nginx-proxy-manager" target="_blank">Tabler</a>
+        <%- i18n('main', 'version', {version: getVersion()}) %>
+        <%= i18n('footer', 'copy', {url: 'https://jc21.com?utm_source=nginx-proxy-manager'}) %>
+        <%= i18n('footer', 'theme', {url: 'https://tabler.github.io/?utm_source=nginx-proxy-manager'}) %>
     </div>
 </div>

+ 2 - 2
src/frontend/js/app/ui/footer/main.js

@@ -2,7 +2,7 @@
 
 const Mn       = require('backbone.marionette');
 const template = require('./main.ejs');
-const App      = require('../../main');
+const Cache    = require('../../cache');
 
 module.exports = Mn.View.extend({
     className: 'container',
@@ -10,7 +10,7 @@ module.exports = Mn.View.extend({
 
     templateContext: {
         getVersion: function () {
-            return App.version;
+            return Cache.version || '0.0.0';
         }
     }
 });

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

@@ -1,7 +1,7 @@
 <div class="container">
     <div class="d-flex">
         <a class="navbar-brand" href="/">
-            <img src="/images/favicons/favicon-32x32.png" border="0"> &nbsp; Nginx Proxy Manager
+            <img src="/images/favicons/favicon-32x32.png" border="0"> &nbsp; <%- i18n('main', 'app') %>
         </a>
 
         <div class="d-flex order-lg-2 ml-auto">
@@ -9,16 +9,16 @@
                 <a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
                     <span class="avatar" style="background-image: url(<%- getUserField('avatar', '/images/default-avatar.jpg') %>)"></span>
                     <span class="ml-2 d-none d-lg-block">
-                      <span class="text-default"><%- getUserField('nickname', null) || getUserField('name', 'Unknown User') %></span>
+                      <span class="text-default"><%- getUserField('nickname', null) || getUserField('name', i18n('main', 'unknown-user')) %></span>
                       <small class="text-muted d-block mt-1"><%- getRole() %></small>
                     </span>
                 </a>
                 <div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
                     <a class="dropdown-item edit-details" href="#">
-                        <i class="dropdown-icon fe fe-user"></i> Edit Details
+                        <i class="dropdown-icon fe fe-user"></i> <%- i18n('users', 'edit-details') %>
                     </a>
                     <a class="dropdown-item change-password" href="#">
-                        <i class="dropdown-icon fe fe-lock"></i> Change Password
+                        <i class="dropdown-icon fe fe-lock"></i> <%- i18n('users', 'change-password') %>
                     </a>
                     <div class="dropdown-divider"></div>
                     <a class="dropdown-item logout" href="/logout">

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

@@ -2,6 +2,7 @@
 
 const $          = require('jquery');
 const Mn         = require('backbone.marionette');
+const i18n       = require('../../i18n');
 const Cache      = require('../../cache');
 const Controller = require('../../controller');
 const Tokens     = require('../../tokens');
@@ -50,15 +51,15 @@ module.exports = Mn.View.extend({
         },
 
         getRole: function () {
-            return Cache.User.isAdmin() ? 'Administrator' : 'Apache Helicopter';
+            return i18n('roles', Cache.User.isAdmin() ? 'admin' : 'user');
         },
 
         getLogoutText: function () {
             if (Tokens.getTokenCount() > 1) {
-                return 'Sign back in as ' + Tokens.getNextTokenName();
+                return i18n('main', 'sign-in-as', {name: Tokens.getNextTokenName()});
             }
 
-            return 'Sign out';
+            return i18n('str', 'sign-out');
         }
     },
 

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

@@ -3,39 +3,39 @@
         <div class="col-lg order-lg-first">
             <ul class="nav nav-tabs border-0 flex-column flex-lg-row">
                 <li class="nav-item">
-                    <a href="/" class="nav-link"><i class="fe fe-home"></i> Dashboard</a>
+                    <a href="/" class="nav-link"><i class="fe fe-home"></i> <%- i18n('menu', 'dashboard') %></a>
                 </li>
                 <li class="nav-item dropdown">
-                    <a href="#" class="nav-link" data-toggle="dropdown"><i class="fe fe-monitor"></i> Hosts</a>
+                    <a href="#" class="nav-link" data-toggle="dropdown"><i class="fe fe-monitor"></i> <%- i18n('menu', 'hosts') %></a>
                     <div class="dropdown-menu dropdown-menu-arrow">
                         <% if (canShow('proxy_hosts')) { %>
-                        <a href="/nginx/proxy" class="dropdown-item ">Proxy Hosts</a>
+                        <a href="/nginx/proxy" class="dropdown-item "><%- i18n('proxy-hosts', 'title') %></a>
                         <% } %>
 
                         <% if (canShow('redirection_hosts')) { %>
-                        <a href="/nginx/redirection" class="dropdown-item ">Redirections</a>
+                        <a href="/nginx/redirection" class="dropdown-item "><%- i18n('redirection-hosts', 'title') %></a>
                         <% } %>
 
                         <% if (canShow('streams')) { %>
-                        <a href="/nginx/stream" class="dropdown-item ">Streams</a>
+                        <a href="/nginx/stream" class="dropdown-item "><%- i18n('streams', 'title') %></a>
                         <% } %>
 
                         <% if (canShow('dead_hosts')) { %>
-                        <a href="/nginx/404" class="dropdown-item ">404 Hosts</a>
+                        <a href="/nginx/404" class="dropdown-item "><%- i18n('dead-hosts', 'title') %></a>
                         <% } %>
                     </div>
                 </li>
                 <% if (canShow('access_lists')) { %>
                 <li class="nav-item">
-                    <a href="/nginx/access" class="nav-link"><i class="fe fe-lock"></i> Access Lists</a>
+                    <a href="/nginx/access" class="nav-link"><i class="fe fe-lock"></i> <%- i18n('access-lists', 'title') %></a>
                 </li>
                 <% } %>
                 <% if (isAdmin()) { %>
                 <li class="nav-item">
-                    <a href="/users" class="nav-link"><i class="fe fe-users"></i> Users</a>
+                    <a href="/users" class="nav-link"><i class="fe fe-users"></i> <%- i18n('users', 'title') %></a>
                 </li>
                 <li class="nav-item">
-                    <a href="/audit-log" class="nav-link"><i class="fe fe-book-open"></i> Audit Log</a>
+                    <a href="/audit-log" class="nav-link"><i class="fe fe-book-open"></i> <%- i18n('audit-log', 'title') %></a>
                 </li>
                 <% } %>
             </ul>

+ 4 - 4
src/frontend/js/app/user/delete.ejs

@@ -1,19 +1,19 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title">Delete <%- name %></h5>
+        <h5 class="modal-title"><%- i18n('users', 'delete', {name: name}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body">
         <form>
             <div class="row">
                 <div class="col-sm-12 col-md-12">
-                    Are you sure you want to delete <strong><%- name %></strong>?
+                    <%= i18n('users', 'delete-confirm', {name: name}) %>
                 </div>
             </div>
         </form>
     </div>
     <div class="modal-footer">
-        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal">Cancel</button>
-        <button type="button" class="btn btn-danger save">Yes I'm Sure</button>
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-danger save"><%- i18n('str', 'sure') %></button>
     </div>
 </div>

+ 5 - 7
src/frontend/js/app/user/delete.js

@@ -1,10 +1,8 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const template   = require('./delete.ejs');
-const Controller = require('../controller');
-const Api        = require('../api');
-const App        = require('../main');
+const Mn       = require('backbone.marionette');
+const template = require('./delete.ejs');
+const App      = require('../main');
 
 require('jquery-serializejson');
 
@@ -24,9 +22,9 @@ module.exports = Mn.View.extend({
         'click @ui.save': function (e) {
             e.preventDefault();
 
-            Api.Users.delete(this.model.get('id'))
+            App.Api.Users.delete(this.model.get('id'))
                 .then(() => {
-                    Controller.showUsers();
+                    App.Controller.showUsers();
                     App.UI.closeModal();
                 })
                 .catch(err => {

+ 9 - 9
src/frontend/js/app/user/form.ejs

@@ -1,6 +1,6 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title"><% if (typeof id !== 'undefined') { %>Edit<% } else { %>New<% } %> User</h5>
+        <h5 class="modal-title"><%- i18n('users', 'form-title', {id: id}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body">
@@ -8,33 +8,33 @@
             <div class="row">
                 <div class="col-sm-6 col-md-6">
                     <div class="form-group">
-                        <label class="form-label">Full Name <span class="form-required">*</span></label>
+                        <label class="form-label"><%- i18n('users', 'full-name') %> <span class="form-required">*</span></label>
                         <input name="name" type="text" class="form-control" placeholder="Joe Citizen" value="<%- name %>" required>
                     </div>
                 </div>
                 <div class="col-sm-6 col-md-6">
                     <div class="form-group">
-                        <label class="form-label">Nickname</label>
+                        <label class="form-label"><%- i18n('users', 'nickname') %></label>
                         <input name="nickname" type="text" class="form-control" placeholder="Joe" value="<%- nickname %>">
                     </div>
                 </div>
                 <div class="col-sm-12 col-md-12">
                     <div class="form-group">
-                        <label class="form-label">Email <span class="form-required">*</span></label>
+                        <label class="form-label"><%- i18n('str', 'email') %> <span class="form-required">*</span></label>
                         <input name="email" type="email" class="form-control" placeholder="[email protected]" value="<%- email %>" required>
                         <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 class="form-label"><%- i18n('roles', 'title') %></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="is_admin" value="1"<%- isAdmin() ? ' checked' : '' %><%- isSelf() ? ' disabled' : '' %>>
                             <span class="custom-switch-indicator"></span>
-                            <span class="custom-switch-description">Administrator</span>
+                            <span class="custom-switch-description"><%- i18n('roles', 'admin') %></span>
                         </label>
                     </div>
                 </div>
@@ -43,7 +43,7 @@
                         <label class="custom-switch">
                             <input type="checkbox" class="custom-switch-input" name="is_disabled" value="1"<%- is_disabled ? ' checked' : '' %><%- isSelf() ? ' disabled' : '' %>>
                             <span class="custom-switch-indicator"></span>
-                            <span class="custom-switch-description">Disabled</span>
+                            <span class="custom-switch-description"><%- i18n('str', 'disabled') %></span>
                         </label>
                     </div>
                 </div>
@@ -52,7 +52,7 @@
         </form>
     </div>
     <div class="modal-footer">
-        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal">Cancel</button>
-        <button type="button" class="btn btn-teal save">Save</button>
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
     </div>
 </div>

+ 17 - 20
src/frontend/js/app/user/form.js

@@ -1,12 +1,9 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const template   = require('./form.ejs');
-const Controller = require('../controller');
-const Cache      = require('../cache');
-const Api        = require('../api');
-const App        = require('../main');
-const UserModel  = require('../../models/user');
+const Mn        = require('backbone.marionette');
+const App       = require('../main');
+const UserModel = require('../../models/user');
+const template  = require('./form.ejs');
 
 require('jquery-serializejson');
 
@@ -34,45 +31,45 @@ module.exports = Mn.View.extend({
 
             // [email protected] is not allowed
             if (data.email === '[email protected]') {
-                this.ui.error.text('Default email address must be changed').show();
+                this.ui.error.text(App.i18n('users', 'default_error')).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)) {
+            if ((this.model.get('id') === App.Cache.User.get('id') && this.model.isAdmin()) || (typeof data.is_admin !== 'undefined' && data.is_admin)) {
                 data.roles.push('admin');
                 delete data.is_admin;
             }
 
             data.is_disabled = typeof data.is_disabled !== 'undefined' ? !!data.is_disabled : false;
             this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
-            let method = Api.Users.create;
+            let method = App.Api.Users.create;
 
             if (this.model.get('id')) {
                 // edit
-                method  = Api.Users.update;
+                method  = App.Api.Users.update;
                 data.id = this.model.get('id');
             }
 
             method(data)
                 .then(result => {
-                    if (result.id === Cache.User.get('id')) {
-                        Cache.User.set(result);
+                    if (result.id === App.Cache.User.get('id')) {
+                        App.Cache.User.set(result);
                     }
 
-                    if (view.model.get('id') !== Cache.User.get('id')) {
-                        Controller.showUsers();
+                    if (view.model.get('id') !== App.Cache.User.get('id')) {
+                        App.Controller.showUsers();
                     }
 
                     view.model.set(result);
-                    App.UI.closeModal(function () {
-                        if (method === Api.Users.create) {
+                    App.App.UI.closeModal(function () {
+                        if (method === App.Api.Users.create) {
                             // Show permissions dialog immediately
-                            Controller.showUserPermissions(view.model);
+                            App.Controller.showUserPermissions(view.model);
                         } else if (show_password) {
-                            Controller.showUserPasswordForm(view.model);
+                            App.Controller.showUserPasswordForm(view.model);
                         }
                     });
                 })
@@ -88,7 +85,7 @@ module.exports = Mn.View.extend({
 
         return {
             isSelf: function () {
-                return view.model.get('id') === Cache.User.get('id');
+                return view.model.get('id') === App.Cache.User.get('id');
             },
 
             isAdmin: function () {

+ 6 - 6
src/frontend/js/app/user/password.ejs

@@ -1,30 +1,30 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title">Change Password <%- isSelf() ? '' : 'for' + name %></h5>
+        <h5 class="modal-title"><%- i18n('users', 'form-title', {self: isSelf(), name: name}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body">
         <form>
             <% if (isSelf()) { %>
             <div class="form-group">
-                <label class="form-label">Current Password</label>
+                <label class="form-label"><%- i18n('users', 'current-password') %></label>
                 <input type="password" name="current_password" class="form-control" placeholder="" minlength="8" required>
             </div>
             <% } %>
 
             <div class="form-group">
-                <label class="form-label">New Password</label>
+                <label class="form-label"><%- i18n('users', 'new-password') %></label>
                 <input type="password" name="new_password1" class="form-control" placeholder="" minlength="8" required>
                 <div class="invalid-feedback secret-error"></div>
             </div>
             <div class="form-group">
-                <label class="form-label">Confirm Password</label>
+                <label class="form-label"><%- i18n('users', 'confirm-password') %></label>
                 <input type="password" name="new_password2" class="form-control" placeholder="" minlength="8" required>
             </div>
         </form>
     </div>
     <div class="modal-footer">
-        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal">Cancel</button>
-        <button type="button" class="btn btn-teal save">Save</button>
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
     </div>
 </div>

+ 6 - 9
src/frontend/js/app/user/password.js

@@ -1,11 +1,8 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const template   = require('./password.ejs');
-const Controller = require('../controller');
-const Api        = require('../api');
-const App        = require('../main');
-const Cache      = require('../cache');
+const Mn       = require('backbone.marionette');
+const App      = require('../main');
+const template = require('./password.ejs');
 
 require('jquery-serializejson');
 
@@ -39,10 +36,10 @@ module.exports = Mn.View.extend({
             };
 
             this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
-            Api.Users.setPassword(this.model.get('id'), data)
+            App.Api.Users.setPassword(this.model.get('id'), data)
                 .then(() => {
                     App.UI.closeModal();
-                    Controller.showUsers();
+                    App.Controller.showUsers();
                 })
                 .catch(err => {
                     this.ui.error.text(err.message).show();
@@ -52,7 +49,7 @@ module.exports = Mn.View.extend({
     },
 
     isSelf: function () {
-        return Cache.User.get('id') === this.model.get('id');
+        return App.Cache.User.get('id') === this.model.get('id');
     },
 
     templateContext: function () {

+ 32 - 104
src/frontend/js/app/user/permissions.ejs

@@ -1,6 +1,6 @@
 <div class="modal-content">
     <div class="modal-header">
-        <h5 class="modal-title">Permissions for <%- name %></h5>
+        <h5 class="modal-title"><%- i18n('users', 'permissions-title', {name: name}) %></h5>
         <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
     </div>
     <div class="modal-body">
@@ -11,130 +11,58 @@
                     <% if (isAdmin()) { %>
                     <div class="alert alert-icon alert-secondary" role="alert">
                         <i class="fe fe-alert-triangle mr-2" aria-hidden="true"></i>
-                        This user is an Administrator and some items cannot be altered
+                        <%- i18n('users', 'admin-perms') %>
                     </div>
                     <% } %>
 
                     <div class="form-group">
-                        <label class="form-label">Item Visibility</label>
+                        <label class="form-label"><%- i18n('users', 'perms-visibility') %></label>
                         <div class="selectgroup w-100">
                             <label class="selectgroup-item">
                                 <input type="radio" name="visibility" value="user" class="selectgroup-input"<%- getPerm('visibility') !== 'all' ? ' checked' : '' %>>
-                                <span class="selectgroup-button">Created Items Only</span>
+                                <span class="selectgroup-button"><%- i18n('users', 'perms-visibility-user') %></span>
                             </label>
                             <label class="selectgroup-item">
                                 <input type="radio" name="visibility" value="all" class="selectgroup-input"<%- getPerm('visibility') === 'all' ? ' checked' : '' %>>
-                                <span class="selectgroup-button">All Items</span>
+                                <span class="selectgroup-button"><%- i18n('users', 'perms-visibility-all') %></span>
                             </label>
                         </div>
                     </div>
                 </div>
 
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">Proxy Hosts</label>
-                        <div class="selectgroup w-100">
-                            <label class="selectgroup-item">
-                                <input type="radio" name="proxy_hosts" value="manage" class="selectgroup-input" <%- getPermProps('proxy_hosts', 'manage', true) %>>
-                                <span class="selectgroup-button">Manage</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="proxy_hosts" value="view" class="selectgroup-input" <%- getPermProps('proxy_hosts', 'view') %>>
-                                <span class="selectgroup-button">View Only</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="proxy_hosts" value="hidden" class="selectgroup-input" <%- getPermProps('proxy_hosts', 'hidden') %>>
-                                <span class="selectgroup-button">Hidden</span>
-                            </label>
-                        </div>
-                    </div>
-                </div>
-
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">Redirection Hosts</label>
-                        <div class="selectgroup w-100">
-                            <label class="selectgroup-item">
-                                <input type="radio" name="redirection_hosts" value="manage" class="selectgroup-input" <%- getPermProps('redirection_hosts', 'manage', true) %>>
-                                <span class="selectgroup-button">Manage</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="redirection_hosts" value="view" class="selectgroup-input" <%- getPermProps('redirection_hosts', 'view') %>>
-                                <span class="selectgroup-button">View Only</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="redirection_hosts" value="hidden" class="selectgroup-input" <%- getPermProps('redirection_hosts', 'hidden') %>>
-                                <span class="selectgroup-button">Hidden</span>
-                            </label>
-                        </div>
-                    </div>
-                </div>
-
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">404 Hosts</label>
-                        <div class="selectgroup w-100">
-                            <label class="selectgroup-item">
-                                <input type="radio" name="dead_hosts" value="manage" class="selectgroup-input" <%- getPermProps('dead_hosts', 'manage', true) %>>
-                                <span class="selectgroup-button">Manage</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="dead_hosts" value="view" class="selectgroup-input" <%- getPermProps('dead_hosts', 'view') %>>
-                                <span class="selectgroup-button">View Only</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="dead_hosts" value="hidden" class="selectgroup-input" <%- getPermProps('dead_hosts', 'hidden') %>>
-                                <span class="selectgroup-button">Hidden</span>
-                            </label>
+                <%
+                var list = ['proxy-hosts', 'redirection-hosts', 'dead-hosts', 'streams', 'access-lists'];
+                list.map(function(item) {
+                    var perm = item.replace('-', '_');
+                    %>
+                    <div class="col-sm-12 col-md-12">
+                        <div class="form-group">
+                            <label class="form-label"><%- i18n(item, 'title') %></label>
+                            <div class="selectgroup w-100">
+                                <label class="selectgroup-item">
+                                    <input type="radio" name="<%- perm %>" value="manage" class="selectgroup-input" <%- getPermProps(perm, 'manage', true) %>>
+                                    <span class="selectgroup-button"><%- i18n('users', 'perm-manage') %></span>
+                                </label>
+                                <label class="selectgroup-item">
+                                    <input type="radio" name="<%- perm %>" value="view" class="selectgroup-input" <%- getPermProps(perm, 'view') %>>
+                                    <span class="selectgroup-button"><%- i18n('users', 'perm-view') %></span>
+                                </label>
+                                <label class="selectgroup-item">
+                                    <input type="radio" name="<%- perm %>" value="hidden" class="selectgroup-input" <%- getPermProps(perm, 'hidden') %>>
+                                    <span class="selectgroup-button"><%- i18n('users', 'perm-hidden') %></span>
+                                </label>
+                            </div>
                         </div>
                     </div>
-                </div>
-
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">Streams</label>
-                        <div class="selectgroup w-100">
-                            <label class="selectgroup-item">
-                                <input type="radio" name="streams" value="manage" class="selectgroup-input" <%- getPermProps('streams', 'manage', true) %>>
-                                <span class="selectgroup-button">Manage</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="streams" value="view" class="selectgroup-input" <%- getPermProps('streams', 'view') %>>
-                                <span class="selectgroup-button">View Only</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="streams" value="hidden" class="selectgroup-input" <%- getPermProps('streams', 'hidden') %>>
-                                <span class="selectgroup-button">Hidden</span>
-                            </label>
-                        </div>
-                    </div>
-                </div>
-
-                <div class="col-sm-12 col-md-12">
-                    <div class="form-group">
-                        <label class="form-label">Access Lists</label>
-                        <div class="selectgroup w-100">
-                            <label class="selectgroup-item">
-                                <input type="radio" name="access_lists" value="manage" class="selectgroup-input" <%- getPermProps('access_lists', 'manage', true) %>>
-                                <span class="selectgroup-button">Manage</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="access_lists" value="view" class="selectgroup-input" <%- getPermProps('access_lists', 'view') %>>
-                                <span class="selectgroup-button">View Only</span>
-                            </label>
-                            <label class="selectgroup-item">
-                                <input type="radio" name="access_lists" value="hidden" class="selectgroup-input" <%- getPermProps('access_lists', 'hidden') %>>
-                                <span class="selectgroup-button">Hidden</span>
-                            </label>
-                        </div>
-                    </div>
-                </div>
+                    <%
+                });
+                %>
 
             </div>
         </form>
     </div>
     <div class="modal-footer">
-        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal">Cancel</button>
-        <button type="button" class="btn btn-teal save">Save</button>
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
     </div>
 </div>

+ 7 - 10
src/frontend/js/app/user/permissions.js

@@ -1,12 +1,9 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const template   = require('./permissions.ejs');
-const Controller = require('../controller');
-const Cache      = require('../cache');
-const Api        = require('../api');
-const App        = require('../main');
-const UserModel  = require('../../models/user');
+const Mn        = require('backbone.marionette');
+const App       = require('../main');
+const UserModel = require('../../models/user');
+const template  = require('./permissions.ejs');
 
 require('jquery-serializejson');
 
@@ -44,10 +41,10 @@ module.exports = Mn.View.extend({
 
             this.ui.buttons.prop('disabled', true).addClass('btn-disabled');
 
-            Api.Users.setPermissions(view.model.get('id'), data)
+            App.Api.Users.setPermissions(view.model.get('id'), data)
                 .then(() => {
-                    if (view.model.get('id') === Cache.User.get('id')) {
-                        Cache.User.set({permissions: data});
+                    if (view.model.get('id') === App.Cache.User.get('id')) {
+                        App.Cache.User.set({permissions: data});
                     }
 
                     view.model.set({permissions: data});

+ 17 - 7
src/frontend/js/app/users/list/item.ejs

@@ -6,26 +6,36 @@
 <td>
     <div><%- name %></div>
     <div class="small text-muted">
-        Created: <%- formatDbDate(created_on, 'Do MMMM YYYY') %>
+        <%- i18n('str', 'created-on', {date: formatDbDate(created_on, 'Do MMMM YYYY')}) %>
     </div>
 </td>
 <td>
     <div><%- email %></div>
 </td>
 <td>
-    <div><%- roles.join(', ') %></div>
+    <div>
+        <%
+        var r = [];
+        roles.map(function(role) {
+            if (role) {
+                r.push(i18n('roles', role));
+            }
+        });
+        %>
+        <%- r.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>
+            <a href="#" class="edit-user dropdown-item"><i class="dropdown-icon fe fe-edit"></i> <%- i18n('users', 'edit-details') %></a>
+            <a href="#" class="edit-permissions dropdown-item"><i class="dropdown-icon fe fe-shield"></i> <%- i18n('users', 'edit-permissions') %></a>
+            <a href="#" class="set-password dropdown-item"><i class="dropdown-icon fe fe-lock"></i> <%- i18n('users', 'change-password') %></a>
             <% if (!isSelf()) { %>
-            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> Sign in as User</a>
+            <a href="#" class="login dropdown-item"><i class="dropdown-icon fe fe-log-in"></i> <%- i18n('users', 'sign-in-as') %></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>
+            <a href="#" class="delete-user dropdown-item"><i class="dropdown-icon fe fe-trash-2"></i> <%- i18n('users', 'delete') %></a>
             <% } %>
         </div>
     </div>

+ 11 - 13
src/frontend/js/app/users/list/item.js

@@ -1,11 +1,9 @@
 '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');
+const Mn       = require('backbone.marionette');
+const App      = require('../../main');
+const Tokens   = require('../../tokens');
+const template = require('./item.ejs');
 
 module.exports = Mn.View.extend({
     template: template,
@@ -22,31 +20,31 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.edit': function (e) {
             e.preventDefault();
-            Controller.showUserForm(this.model);
+            App.Controller.showUserForm(this.model);
         },
 
         'click @ui.permissions': function (e) {
             e.preventDefault();
-            Controller.showUserPermissions(this.model);
+            App.Controller.showUserPermissions(this.model);
         },
 
         'click @ui.password': function (e) {
             e.preventDefault();
-            Controller.showUserPasswordForm(this.model);
+            App.Controller.showUserPasswordForm(this.model);
         },
 
         'click @ui.delete': function (e) {
             e.preventDefault();
-            Controller.showUserDeleteConfirm(this.model);
+            App.Controller.showUserDeleteConfirm(this.model);
         },
 
         'click @ui.login': function (e) {
             e.preventDefault();
 
-            if (Cache.User.get('id') !== this.model.get('id')) {
+            if (App.Cache.User.get('id') !== this.model.get('id')) {
                 this.ui.login.prop('disabled', true).addClass('btn-disabled');
 
-                Api.Users.loginAs(this.model.get('id'))
+                App.Api.Users.loginAs(this.model.get('id'))
                     .then(res => {
                         Tokens.addToken(res.token, res.user.nickname || res.user.name);
                         window.location = '/';
@@ -62,7 +60,7 @@ module.exports = Mn.View.extend({
 
     templateContext: {
         isSelf: function () {
-            return Cache.User.get('id') === this.id;
+            return App.Cache.User.get('id') === this.id;
         }
     },
 

+ 3 - 3
src/frontend/js/app/users/list/main.ejs

@@ -1,8 +1,8 @@
 <thead>
     <th width="30">&nbsp;</th>
-    <th>Name</th>
-    <th>Email</th>
-    <th>Roles</th>
+    <th><%- i18n('str', 'name') %></th>
+    <th><%- i18n('str', 'email') %></th>
+    <th><%- i18n('str', 'roles') %></th>
     <th>&nbsp;</th>
 </thead>
 <tbody>

+ 3 - 3
src/frontend/js/app/users/list/main.js

@@ -1,8 +1,8 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const ItemView   = require('./item');
-const template   = require('./main.ejs');
+const Mn       = require('backbone.marionette');
+const ItemView = require('./item');
+const template = require('./main.ejs');
 
 const TableBody = Mn.CollectionView.extend({
     tagName:   'tbody',

+ 2 - 2
src/frontend/js/app/users/main.ejs

@@ -1,8 +1,8 @@
 <div class="card">
     <div class="card-header">
-        <h3 class="card-title">Users</h3>
+        <h3 class="card-title"><%- i18n('users', 'title') %></h3>
         <div class="card-options">
-            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-user">Add User</a>
+            <a href="#" class="btn btn-outline-teal btn-sm ml-2 add-user"><%- i18n('users', 'add') %></a>
         </div>
     </div>
     <div class="card-body no-padding min-100">

+ 13 - 12
src/frontend/js/app/users/main.js

@@ -1,12 +1,11 @@
 'use strict';
 
-const Mn         = require('backbone.marionette');
-const UserModel  = require('../../models/user');
-const Api        = require('../api');
-const Controller = require('../controller');
-const ListView   = require('./list/main');
-const template   = require('./main.ejs');
-const ErrorView  = require('../error/main');
+const Mn        = require('backbone.marionette');
+const App       = require('../main');
+const UserModel = require('../../models/user');
+const ListView  = require('./list/main');
+const ErrorView = require('../error/main');
+const template  = require('./main.ejs');
 
 module.exports = Mn.View.extend({
     id:       'users',
@@ -25,14 +24,14 @@ module.exports = Mn.View.extend({
     events: {
         'click @ui.add': function (e) {
             e.preventDefault();
-            Controller.showUserForm(new UserModel.Model());
+            App.Controller.showUserForm(new UserModel.Model());
         }
     },
 
     onRender: function () {
         let view = this;
 
-        Api.Users.getAll(['permissions'])
+        App.Api.Users.getAll(['permissions'])
             .then(response => {
                 if (!view.isDestroyed() && response && response.length) {
                     view.showChildView('list_region', new ListView({
@@ -42,9 +41,11 @@ module.exports = Mn.View.extend({
             })
             .catch(err => {
                 view.showChildView('list_region', new ErrorView({
-                    code:      err.code,
-                    message:   err.message,
-                    retry:     function () { Controller.showUsers(); }
+                    code:    err.code,
+                    message: err.message,
+                    retry:   function () {
+                        App.Controller.showUsers();
+                    }
                 }));
 
                 console.error(err);

+ 128 - 0
src/frontend/js/i18n/messages.json

@@ -0,0 +1,128 @@
+{
+  "en": {
+    "str": {
+      "email-address": "Email address",
+      "password": "Password",
+      "sign-in": "Sign in",
+      "sign-out": "Sign out",
+      "try-again": "Try again",
+      "name": "Name",
+      "email": "Email",
+      "roles": "Roles",
+      "created-on": "Created: {date}",
+      "save": "Save",
+      "cancel": "Cancel",
+      "sure": "Yes I'm Sure",
+      "disabled": "Disabled",
+      "choose-file": "Choose file",
+      "source": "Source",
+      "destination": "Destination",
+      "ssl": "SSL",
+      "access": "Access",
+      "public": "Public",
+      "edit": "Edit",
+      "delete": "Delete",
+      "logs": "Logs"
+    },
+    "login": {
+      "title": "Login to your account"
+    },
+    "main": {
+      "app": "Nginx Proxy Manager",
+      "version": "v{version}",
+      "welcome": "Welcome to Nginx Proxy Manager",
+      "logged-in": "You are logged in as {name}",
+      "unknown-error": "Error loading stuff. Please reload the app.",
+      "unknown-user": "Unknown User",
+      "sign-in-as": "Sign back in as {name}"
+    },
+    "roles": {
+      "title": "Roles",
+      "admin": "Administrator",
+      "user": "Apache Helicopter"
+    },
+    "menu": {
+      "dashboard": "Dashboard",
+      "hosts": "Hosts"
+    },
+    "footer": {
+      "fork-me": "Fork me on Github",
+      "copy": "&copy; 2018 <a href=\"{url}\" target=\"_blank\">jc21.com</a>.",
+      "theme": "Theme by <a href=\"{url}\" target=\"_blank\">Tabler</a>"
+    },
+    "dashboard": {
+      "title": "Hi {name}"
+    },
+    "all-hosts": {
+      "empty-subtitle": "{manage, select, true{Why don't you create one?} other{And you don't have permission to create one.}}",
+      "details": "Details",
+      "enable-ssl": "Enable SSL",
+      "force-ssl": "Force SSL",
+      "domain-names": "Domain Names",
+      "cert-provider": "Certificate Provider",
+      "other-certificate": "Certificate",
+      "other-certificate-key": "Certificate Key"
+    },
+    "ssl": {
+      "letsencrypt": "Let's Encrypt",
+      "other": "Other",
+      "none": "HTTP only",
+      "letsencrypt-email": "Email Address for Let's Encrypt",
+      "letsencrypt-agree": "I Agree to the <a href=\"{url}\" target=\"_blank\">Let's Encrypt Terms of Service</a>"
+    },
+    "proxy-hosts": {
+      "title": "Proxy Hosts",
+      "empty": "There are no Proxy Hosts",
+      "add": "Add Proxy Host",
+      "form-title": "{id, select, undefined{New} other{Edit}} Proxy Host",
+      "forward-ip": "Forward IP",
+      "forward-port": "Forward Port",
+      "delete": "Delete Proxy Host",
+      "delete-confirm": "Are you sure you want to delete the Proxy host for: <strong>{domains}</strong>?",
+      "delete-ssl": "The SSL certificates attached will be removed, this action cannot be recovered."
+    },
+    "redirection-hosts": {
+      "title": "Redirection Hosts"
+    },
+    "dead-hosts": {
+      "title": "404 Hosts"
+    },
+    "streams": {
+      "title": "Streams"
+    },
+    "access-lists": {
+      "title": "Access Lists"
+    },
+    "users": {
+      "title": "Users",
+      "default_error": "Default email address must be changed",
+      "add": "Add User",
+      "nickname": "Nickname",
+      "full-name": "Full Name",
+      "edit-details": "Edit Details",
+      "change-password": "Change Password",
+      "edit-permissions": "Edit Permissions",
+      "sign-in-as": "Sign in as User",
+      "form-title": "{id, select, undefined{New} other{Edit}} User",
+      "delete": "Delete {name, select, undefined{User} other{{name}}}",
+      "delete-confirm": "Are you sure you want to delete <strong>{name}</strong>?",
+      "password-title": "Change Password{self, select, false{ for {name}} other{}}",
+      "current-password": "Current Password",
+      "new-password": "New Password",
+      "confirm-password": "Confirm Password",
+      "permissions-title": "Permissions for {name}",
+      "admin-perms": "This user is an Administrator and some items cannot be altered",
+      "perms-visibility": "Item Visibility",
+      "perms-visibility-user": "Created Items Only",
+      "perms-visibility-all": "All Items",
+      "perm-manage": "Manage",
+      "perm-view": "View Only",
+      "perm-hidden": "Hidden"
+    },
+    "audit-log": {
+      "title": "Audit Log",
+      "empty": "There are no logs.",
+      "empty-subtitle": "As soon as you or another user changes something, history of those events will show up here."
+    }
+  }
+}

+ 1 - 23
src/frontend/js/lib/helpers.js

@@ -1,36 +1,14 @@
 'use strict';
 
 const numeral = require('numeral');
-const moment  = require('moment');
 
 module.exports = {
 
     /**
-     * @param {Integer} number
+     * @param   {Integer} number
      * @returns {String}
      */
     niceNumber: function (number) {
         return numeral(number).format('0,0');
-    },
-
-    /**
-     * @param   {String|Integer} date
-     * @returns {String}
-     */
-    shortTime: function (date) {
-        let shorttime = '';
-
-        if (typeof date === 'number') {
-            shorttime = moment.unix(date).format('H:mm A');
-        } else {
-            shorttime = moment(date).format('H:mm A');
-        }
-
-        return shorttime;
-    },
-
-    replaceSlackLinks: function (content) {
-        return content.replace(/<(http[^|>]+)\|([^>]+)>/gi, '<a href="$1" target="_blank">$2</a>');
     }
-
 };

+ 5 - 89
src/frontend/js/lib/marionette.js

@@ -1,9 +1,9 @@
 'use strict';
 
-const _       = require('underscore');
-const Mn      = require('backbone.marionette');
-const moment  = require('moment');
-const numeral = require('numeral');
+const _      = require('underscore');
+const Mn     = require('backbone.marionette');
+const moment = require('moment');
+const i18n   = require('../app/i18n');
 
 let render = Mn.Renderer.render;
 
@@ -11,29 +11,7 @@ Mn.Renderer.render = function (template, data, view) {
 
     data = _.clone(data);
 
-    /**
-     * @param   {Integer} number
-     * @returns {String}
-     */
-    data.niceNumber = function (number) {
-        return numeral(number).format('0,0');
-    };
-
-    /**
-     * @param   {Integer} seconds
-     * @returns {String}
-     */
-    data.secondsToTime = function (seconds) {
-        let sec_num = parseInt(seconds, 10);
-        let minutes = Math.floor(sec_num / 60);
-        let sec     = sec_num - (minutes * 60);
-
-        if (sec < 10) {
-            sec = '0' + sec;
-        }
-
-        return minutes + ':' + sec;
-    };
+    data.i18n = i18n;
 
     /**
      * @param   {String} date
@@ -47,68 +25,6 @@ Mn.Renderer.render = function (template, data, view) {
         return moment(date).format(format);
     };
 
-    /**
-     * @param   {String} date
-     * @returns {String}
-     */
-    data.shortDate = function (date) {
-        let shortdate = data.formatDbDate(date, 'YYYY-MM-DD');
-
-        return moment().format('YYYY-MM-DD') === shortdate ? 'Today' : shortdate;
-    };
-
-    /**
-     * @param   {String} date
-     * @returns {String}
-     */
-    data.shortTime = function (date) {
-        return data.formatDbDate(date, 'H:mm A');
-    };
-
-    /**
-     * @param   {String} string
-     * @returns {String}
-     */
-    data.escape = function (string) {
-        let entityMap = {
-            '&':  '&amp;',
-            '<':  '&lt;',
-            '>':  '&gt;',
-            '"':  '&quot;',
-            '\'': '&#39;',
-            '/':  '&#x2F;'
-        };
-
-        return String(string).replace(/[&<>"'\/]/g, function (s) {
-            return entityMap[s];
-        });
-    };
-
-    /**
-     * @param   {String} string
-     * @param   {Integer} length
-     * @returns {String}
-     */
-    data.trim = function (string, length) {
-        if (string.length > length) {
-            let trimmedString = string.substr(0, length);
-            return trimmedString.substr(0, Math.min(trimmedString.length, trimmedString.lastIndexOf(' '))) + '...';
-        }
-
-        return string;
-    };
-
-    /**
-     * @param   {String} name
-     * @returns {String}
-     */
-    data.niceVarName = function (name) {
-        return name.replace('_', ' ')
-            .replace(/^(.)|\s+(.)/g, function ($1) {
-                return $1.toUpperCase();
-            });
-    };
-
     return render.call(this, template, data, view);
 };
 

+ 7 - 7
src/frontend/js/login/ui/login.ejs

@@ -3,23 +3,23 @@
         <div class="col col-login mx-auto">
             <form class="card" action="" method="post">
                 <div class="card-body p-6">
-                    <div class="card-title">Login to your account</div>
+                    <div class="card-title"><%- i18n('login', 'title') %></div>
                     <div class="form-group">
-                        <label class="form-label">Email address</label>
-                        <input name="identity" type="email" class="form-control" placeholder="Enter email" required>
+                        <label class="form-label"><%- i18n('str', 'email-address') %></label>
+                        <input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" required>
                     </div>
                     <div class="form-group">
-                        <label class="form-label">Password</label>
-                        <input name="secret" type="password" class="form-control" placeholder="Password" required>
+                        <label class="form-label"><%- i18n('str', 'password') %></label>
+                        <input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
                         <div class="invalid-feedback secret-error"></div>
                     </div>
                     <div class="form-footer">
-                        <button type="submit" class="btn btn-teal btn-block">Sign in</button>
+                        <button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
                     </div>
                 </div>
             </form>
             <div class="text-center text-muted">
-                Nginx Proxy Manager v<%- getVersion() %>
+                <%- i18n('main', 'app') %> <%- i18n('main', 'version', {version: getVersion()}) %>
             </div>
         </div>
     </div>

+ 2 - 0
src/frontend/js/login/ui/login.js

@@ -4,6 +4,7 @@ const $        = require('jquery');
 const Mn       = require('backbone.marionette');
 const template = require('./login.ejs');
 const Api      = require('../../app/api');
+const i18n     = require('../../app/i18n');
 
 module.exports = Mn.View.extend({
     template:  template,
@@ -35,6 +36,7 @@ module.exports = Mn.View.extend({
     },
 
     templateContext: {
+        i18n:       i18n,
         getVersion: function () {
             return $('#login').data('version');
         }

+ 8 - 7
src/frontend/js/models/dead-host.js

@@ -7,13 +7,14 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
-            created_on:          null,
-            modified_on:         null,
-            owner:               null,
-            domain_name:         '',
-            ssl_enabled:         false,
-            ssl_provider:        false,
-            meta:                []
+            id:           0,
+            created_on:   null,
+            modified_on:  null,
+            owner:        null,
+            domain_name:  '',
+            ssl_enabled:  false,
+            ssl_provider: false,
+            meta:         []
         };
     }
 });

+ 6 - 3
src/frontend/js/models/proxy-host.js

@@ -7,10 +7,10 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
+            id:              0,
             created_on:      null,
             modified_on:     null,
-            owner:           null,
-            domain_name:     '',
+            domain_names:    [],
             forward_ip:      '',
             forward_port:    null,
             access_list_id:  null,
@@ -19,7 +19,10 @@ const model = Backbone.Model.extend({
             ssl_forced:      false,
             caching_enabled: false,
             block_exploits:  false,
-            meta:            []
+            meta:            [],
+            // The following are expansions:
+            owner:           null,
+            access_list:     null
         };
     }
 });

+ 1 - 0
src/frontend/js/models/redirection-host.js

@@ -7,6 +7,7 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
+            id:                  0,
             created_on:          null,
             modified_on:         null,
             owner:               null,

+ 1 - 0
src/frontend/js/models/stream.js

@@ -7,6 +7,7 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
+            id:              0,
             created_on:      null,
             modified_on:     null,
             owner:           null,

+ 1 - 0
src/frontend/js/models/user.js

@@ -8,6 +8,7 @@ const model = Backbone.Model.extend({
 
     defaults: function () {
         return {
+            id:          0,
             name:        '',
             nickname:    '',
             email:       '',

+ 13 - 0
webpack.config.js

@@ -45,6 +45,19 @@ module.exports = {
             },
 
             // other:
+            {
+                type:    'javascript/auto', // <= Set the module.type explicitly
+                test:    /\bmessages\.json$/,
+                loader:  'messageformat-loader',
+                options: {
+                    biDiSupport:            false,
+                    disablePluralKeyChecks: false,
+                    formatters:             null,
+                    intlSupport:            false,
+                    locale:                 ['en'/*, 'es'*/],
+                    strictNumberSign:       false
+                }
+            },
             {
                 test:    /\.js$/,
                 exclude: /node_modules/,