Jamie Curnow hace 7 años
padre
commit
b38b988da4
Se han modificado 40 ficheros con 1419 adiciones y 0 borrados
  1. BIN
      src/frontend/app-images/default-avatar.jpg
  2. BIN
      src/frontend/app-images/favicons/android-chrome-192x192.png
  3. BIN
      src/frontend/app-images/favicons/android-chrome-512x512.png
  4. BIN
      src/frontend/app-images/favicons/apple-touch-icon.png
  5. 9 0
      src/frontend/app-images/favicons/browserconfig.xml
  6. BIN
      src/frontend/app-images/favicons/favicon-16x16.png
  7. BIN
      src/frontend/app-images/favicons/favicon-32x32.png
  8. BIN
      src/frontend/app-images/favicons/favicon.ico
  9. 18 0
      src/frontend/app-images/favicons/manifest.json
  10. BIN
      src/frontend/app-images/favicons/mstile-150x150.png
  11. 32 0
      src/frontend/app-images/favicons/safari-pinned-tab.svg
  12. 1 0
      src/frontend/fonts
  13. 1 0
      src/frontend/images
  14. 224 0
      src/frontend/js/app/api.js
  15. 10 0
      src/frontend/js/app/cache.js
  16. 107 0
      src/frontend/js/app/controller.js
  17. 1 0
      src/frontend/js/app/dashboard/main.ejs
  18. 10 0
      src/frontend/js/app/dashboard/main.js
  19. 167 0
      src/frontend/js/app/main.js
  20. 33 0
      src/frontend/js/app/profile/main.ejs
  21. 21 0
      src/frontend/js/app/profile/main.js
  22. 17 0
      src/frontend/js/app/router.js
  23. 128 0
      src/frontend/js/app/tokens.js
  24. 14 0
      src/frontend/js/app/ui/footer/main.ejs
  25. 16 0
      src/frontend/js/app/ui/footer/main.js
  26. 28 0
      src/frontend/js/app/ui/header/main.ejs
  27. 55 0
      src/frontend/js/app/ui/header/main.js
  28. 18 0
      src/frontend/js/app/ui/main.ejs
  29. 44 0
      src/frontend/js/app/ui/main.js
  30. 56 0
      src/frontend/js/app/ui/menu/main.ejs
  31. 10 0
      src/frontend/js/app/ui/menu/main.js
  32. 112 0
      src/frontend/js/index.js
  33. 36 0
      src/frontend/js/lib/helpers.js
  34. 117 0
      src/frontend/js/lib/marionette.js
  35. 5 0
      src/frontend/js/login.js
  36. 17 0
      src/frontend/js/login/main.js
  37. 28 0
      src/frontend/js/login/ui/login.ejs
  38. 42 0
      src/frontend/js/login/ui/login.js
  39. 29 0
      src/frontend/js/models/user.js
  40. 13 0
      src/frontend/scss/styles.scss

BIN
src/frontend/app-images/default-avatar.jpg


BIN
src/frontend/app-images/favicons/android-chrome-192x192.png


BIN
src/frontend/app-images/favicons/android-chrome-512x512.png


BIN
src/frontend/app-images/favicons/apple-touch-icon.png


+ 9 - 0
src/frontend/app-images/favicons/browserconfig.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+    <msapplication>
+        <tile>
+            <square150x150logo src="/images/favicon/mstile-150x150.png"/>
+            <TileColor>#f0ad00</TileColor>
+        </tile>
+    </msapplication>
+</browserconfig>

BIN
src/frontend/app-images/favicons/favicon-16x16.png


BIN
src/frontend/app-images/favicons/favicon-32x32.png


BIN
src/frontend/app-images/favicons/favicon.ico


+ 18 - 0
src/frontend/app-images/favicons/manifest.json

@@ -0,0 +1,18 @@
+{
+    "name": "",
+    "icons": [
+        {
+            "src": "/images/favicon/android-chrome-192x192.png",
+            "sizes": "192x192",
+            "type": "image/png"
+        },
+        {
+            "src": "/images/favicon/android-chrome-512x512.png",
+            "sizes": "512x512",
+            "type": "image/png"
+        }
+    ],
+    "theme_color": "#ffffff",
+    "background_color": "#ffffff",
+    "display": "standalone"
+}

BIN
src/frontend/app-images/favicons/mstile-150x150.png


+ 32 - 0
src/frontend/app-images/favicons/safari-pinned-tab.svg

@@ -0,0 +1,32 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.11, written by Peter Selinger 2001-2013
+</metadata>
+<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M2384 5110 c-1036 -74 -1922 -761 -2248 -1743 -146 -437 -170 -915
+-70 -1367 193 -869 838 -1582 1687 -1864 437 -146 915 -170 1367 -70 632 141
+1204 533 1564 1074 222 333 358 697 412 1100 22 167 22 473 0 640 -96 722
+-473 1348 -1062 1767 -472 335 -1074 504 -1650 463z m1514 -1050 c173 -18 201
+-52 210 -250 8 -190 -30 -414 -110 -655 -112 -338 -282 -619 -509 -842 -131
+-130 -233 -203 -384 -278 -211 -105 -410 -162 -663 -188 l-114 -12 -97 -109
+c-201 -228 -505 -449 -846 -616 -210 -102 -316 -133 -338 -98 -25 39 106 345
+248 578 166 275 296 430 548 657 36 32 47 49 47 72 l0 30 -137 -66 c-229 -109
+-615 -273 -644 -273 -15 0 -35 7 -43 16 -24 24 -20 109 8 169 38 82 425 784
+473 860 88 136 163 193 292 221 71 15 247 18 324 5 l48 -8 49 61 c305 381 900
+682 1434 726 50 4 96 8 101 8 6 1 52 -3 103 -8z m-558 -2245 c-21 -148 -73
+-226 -214 -319 -97 -64 -842 -472 -899 -492 -47 -17 -110 -18 -138 -4 -13 7
+-19 21 -19 44 0 29 102 278 230 563 36 81 28 76 165 88 273 25 602 139 810
+283 l70 48 3 -65 c2 -36 -2 -102 -8 -146z"/>
+<path d="M3580 3711 c-72 -23 -112 -76 -112 -151 0 -88 62 -152 150 -153 194
+-3 214 278 22 307 -19 3 -46 1 -60 -3z"/>
+<path d="M3035 3392 c-68 -33 -128 -93 -158 -161 -31 -69 -29 -178 5 -252 54
+-117 159 -184 288 -184 96 1 169 33 234 106 60 66 80 129 74 228 -7 118 -59
+201 -160 256 -44 24 -67 30 -138 33 -77 3 -90 1 -145 -26z"/>
+</g>
+</svg>

+ 1 - 0
src/frontend/fonts

@@ -0,0 +1 @@
+../../node_modules/tabler-ui/dist/assets/fonts

+ 1 - 0
src/frontend/images

@@ -0,0 +1 @@
+../../node_modules/tabler-ui/dist/assets/images

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

@@ -0,0 +1,224 @@
+'use strict';
+
+const $      = require('jquery');
+const _      = require('underscore');
+const Tokens = require('./tokens');
+
+/**
+ * @param {String}  message
+ * @param {*}       debug
+ * @param {Integer} code
+ * @constructor
+ */
+const ApiError = function (message, debug, code) {
+    let temp  = Error.call(this, message);
+    temp.name = this.name = 'ApiError';
+    this.stack   = temp.stack;
+    this.message = temp.message;
+    this.debug   = debug;
+    this.code    = code;
+};
+
+ApiError.prototype = Object.create(Error.prototype, {
+    constructor: {
+        value:        ApiError,
+        writable:     true,
+        configurable: true
+    }
+});
+
+/**
+ *
+ * @param   {String} verb
+ * @param   {String} path
+ * @param   {Object} [data]
+ * @param   {Object} [options]
+ * @returns {Promise}
+ */
+function fetch (verb, path, data, options) {
+    options = options || {};
+
+    return new Promise(function (resolve, reject) {
+        let api_url = '/api/';
+        let url     = api_url + path;
+        let token   = Tokens.getTopToken();
+
+        $.ajax({
+            url:         url,
+            data:        typeof data === 'object' ? JSON.stringify(data) : data,
+            type:        verb,
+            dataType:    'json',
+            contentType: 'application/json; charset=UTF-8',
+            crossDomain: true,
+            timeout:     (options.timeout ? options.timeout : 15000),
+            xhrFields:   {
+                withCredentials: true
+            },
+
+            beforeSend: function (xhr) {
+                xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null));
+            },
+
+            success: function (data, textStatus, response) {
+                let total = response.getResponseHeader('X-Dataset-Total');
+                if (total !== null) {
+                    resolve({
+                        data:       data,
+                        pagination: {
+                            total:  parseInt(total, 10),
+                            offset: parseInt(response.getResponseHeader('X-Dataset-Offset'), 10),
+                            limit:  parseInt(response.getResponseHeader('X-Dataset-Limit'), 10)
+                        }
+                    });
+                } else {
+                    resolve(response);
+                }
+            },
+
+            error: function (xhr, status, error_thrown) {
+                let code = 400;
+
+                if (typeof xhr.responseJSON !== 'undefined' && typeof xhr.responseJSON.error !== 'undefined' && typeof xhr.responseJSON.error.message !== 'undefined') {
+                    error_thrown = xhr.responseJSON.error.message;
+                    code         = xhr.responseJSON.error.code || 500;
+                }
+
+                reject(new ApiError(error_thrown, xhr.responseText, code));
+            }
+        });
+    });
+}
+
+/**
+ *
+ * @param {Array} expand
+ * @returns {String}
+ */
+function makeExpansionString (expand) {
+    let items = [];
+    _.forEach(expand, function (exp) {
+        items.push(encodeURIComponent(exp));
+    });
+
+    return items.join(',');
+}
+
+module.exports = {
+    status: function () {
+        return fetch('get', '');
+    },
+
+    Tokens: {
+
+        /**
+         * @param   {String}  identity
+         * @param   {String}  secret
+         * @param   {Boolean} [wipe]       Will wipe the stack before adding to it again if login was successful
+         * @returns {Promise}
+         */
+        login: function (identity, secret, wipe) {
+            return fetch('post', 'tokens', {identity: identity, secret: secret})
+                .then(response => {
+                    if (response.token) {
+                        if (wipe) {
+                            Tokens.clearTokens();
+                        }
+
+                        // Set storage token
+                        Tokens.addToken(response.token);
+                        return response.token;
+                    } else {
+                        Tokens.clearTokens();
+                        throw(new Error('No token returned'));
+                    }
+                });
+        },
+
+        /**
+         * @returns {Promise}
+         */
+        refresh: function () {
+            return fetch('get', 'tokens')
+                .then(response => {
+                    if (response.token) {
+                        Tokens.setCurrentToken(response.token);
+                        return response.token;
+                    } else {
+                        Tokens.clearTokens();
+                        throw(new Error('No token returned'));
+                    }
+                });
+        }
+    },
+
+    Users: {
+
+        /**
+         * @param   {Integer|String}  user_id
+         * @param   {Array}           [expand]
+         * @returns {Promise}
+         */
+        getById: function (user_id, expand) {
+            return fetch('get', 'users/' + user_id + (typeof expand === 'object' && expand.length ? '?expand=' + makeExpansionString(expand) : ''));
+        },
+
+        /**
+         * @param   {Integer}  [offset]
+         * @param   {Integer}  [limit]
+         * @param   {String}   [sort]
+         * @param   {Array}    [expand]
+         * @param   {String}   [query]
+         * @returns {Promise}
+         */
+        getAll: function (offset, limit, sort, expand, query) {
+            return fetch('get', 'users?offset=' + (offset ? offset : 0) + '&limit=' + (limit ? limit : 20) + (sort ? '&sort=' + sort : '') +
+                (typeof expand === 'object' && expand !== null && expand.length ? '&expand=' + makeExpansionString(expand) : '') +
+                (typeof query === 'string' ? '&query=' + query : ''));
+        },
+
+        /**
+         * @param   {Object}  data
+         * @returns {Promise}
+         */
+        create: function (data) {
+            return fetch('post', 'users', data);
+        },
+
+        /**
+         * @param   {Object}   data
+         * @param   {Integer}  data.id
+         * @returns {Promise}
+         */
+        update: function (data) {
+            let id = data.id;
+            delete data.id;
+            return fetch('put', 'users/' + id, data);
+        },
+
+        /**
+         * @param   {Integer}  id
+         * @returns {Promise}
+         */
+        delete: function (id) {
+            return fetch('delete', 'users/' + id);
+        },
+
+        /**
+         *
+         * @param   {Integer}  id
+         * @param   {Object}   auth
+         * @returns {Promise}
+         */
+        setPassword: function (id, auth) {
+            return fetch('put', 'users/' + id + '/auth', auth);
+        },
+
+        /**
+         * @param   {Integer}  id
+         * @returns {Promise}
+         */
+        loginAs: function (id) {
+            return fetch('post', 'users/' + id + '/login');
+        }
+    }
+};

+ 10 - 0
src/frontend/js/app/cache.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const UserModel = require('../models/user');
+
+let cache = {
+    User: new UserModel.Model()
+};
+
+module.exports = cache;
+

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

@@ -0,0 +1,107 @@
+'use strict';
+
+const Backbone = require('backbone');
+const Cache    = require('./cache');
+const Tokens   = require('./tokens');
+
+module.exports = {
+
+    /**
+     * @param {String} route
+     * @param {Object} [options]
+     * @returns {Boolean}
+     */
+    navigate: function (route, options) {
+        options = options || {};
+        Backbone.history.navigate(route.toString(), options);
+        return true;
+    },
+
+    /**
+     * Login
+     */
+    showLogin: function () {
+        window.location = '/login';
+    },
+
+    /**
+     * Users
+     *
+     * @param {Number}  [offset]
+     * @param {Number}  [limit]
+     * @param {String}  [sort]
+     */
+    showUsers: function (offset, limit, sort) {
+        /*
+        let controller = this;
+        if (Cache.User.isAdmin()) {
+            require(['./main', './users/main'], (App, View) => {
+                controller.navigate('/users');
+                App.UI.showMainLoading();
+                let view = new View({
+                    sort:   (typeof sort !== 'undefined' && sort ? sort : Cache.Session.Users.sort),
+                    offset: (typeof offset !== 'undefined' ? offset : Cache.Session.Users.offset),
+                    limit:  (typeof limit !== 'undefined' && limit ? limit : Cache.Session.Users.limit)
+                });
+
+                view.on('loaded', function () {
+                    App.UI.hideMainLoading();
+                });
+
+                App.UI.showAppContent(view);
+            });
+        } else {
+            this.showRules();
+        }
+        */
+    },
+
+    /**
+     * Error
+     *
+     * @param {Error}   err
+     * @param {String}  nice_msg
+     */
+    /*
+    showError: function (err, nice_msg) {
+        require(['./main', './error/main'], (App, View) => {
+            App.UI.showAppContent(new View({
+                err:      err,
+                nice_msg: nice_msg
+            }));
+        });
+    },
+    */
+
+    /**
+     * Dashboard
+     */
+    showDashboard: function () {
+        let controller = this;
+
+        require(['./main', './dashboard/main'], (App, View) => {
+            controller.navigate('/');
+            App.UI.showAppContent(new View());
+        });
+    },
+
+    /**
+     * Dashboard
+     */
+    showProfile: function () {
+        let controller = this;
+
+        require(['./main', './profile/main'], (App, View) => {
+            controller.navigate('/profile');
+            App.UI.showAppContent(new View());
+        });
+    },
+
+    /**
+     * Logout
+     */
+    logout: function () {
+        Tokens.dropTopToken();
+        this.showLogin();
+    }
+};

+ 1 - 0
src/frontend/js/app/dashboard/main.ejs

@@ -0,0 +1 @@
+Hi

+ 10 - 0
src/frontend/js/app/dashboard/main.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const template = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    template: template,
+    id:       'dashboard'
+});
+

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

@@ -0,0 +1,167 @@
+'use strict';
+
+const _          = require('underscore');
+const Backbone   = require('backbone');
+const Mn         = require('../lib/marionette');
+const Cache      = require('./cache');
+const Controller = require('./controller');
+const Router     = require('./router');
+const Api        = require('./api');
+const Tokens     = require('./tokens');
+const UI         = require('./ui/main');
+
+const App = Mn.Application.extend({
+
+    region:     '#app',
+    Cache:      Cache,
+    Api:        Api,
+    UI:         null,
+    Controller: Controller,
+    version:    null,
+
+    onStart: function (app, options) {
+        console.log('Welcome to Nginx Proxy Manager');
+
+        // Check if token is coming through
+        if (this.getParam('token')) {
+            Tokens.addToken(this.getParam('token'));
+        }
+
+        // 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('.');
+            })
+            .then(Api.Tokens.refresh)
+            .then(this.bootstrap)
+            .then(() => {
+                console.info('You are logged in');
+                this.bootstrapTimer();
+                this.refreshTokenTimer();
+
+                this.UI = new UI();
+                this.UI.on('render', () => {
+                    new Router(options);
+                    Backbone.history.start({pushState: true});
+                });
+
+                this.getRegion().show(this.UI);
+            })
+            .catch(err => {
+                console.warn('Not logged in:', err.message);
+                Controller.showLogin();
+            });
+
+    },
+
+    History: {
+        replace: function (data) {
+            window.history.replaceState(_.extend(window.history.state || {}, data), document.title);
+        },
+
+        get: function (attr) {
+            return window.history.state ? window.history.state[attr] : undefined;
+        }
+    },
+
+    Error: function (code, message, debug) {
+        let temp  = Error.call(this, message);
+        temp.name = this.name = 'AppError';
+        this.stack   = temp.stack;
+        this.message = temp.message;
+        this.code    = code;
+        this.debug   = debug;
+    },
+
+    showError: function () {
+        let ErrorView = Mn.View.extend({
+            tagName:  'section',
+            id:       'error',
+            template: _.template('Error loading stuff. Please reload the app.')
+        });
+
+        this.getRegion().show(new ErrorView());
+    },
+
+    getParam: function (name) {
+        name        = name.replace(/[\[\]]/g, '\\$&');
+        let url     = window.location.href;
+        let regex   = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)');
+        let results = regex.exec(url);
+
+        if (!results) {
+            return null;
+        }
+
+        if (!results[2]) {
+            return '';
+        }
+
+        return decodeURIComponent(results[2].replace(/\+/g, ' '));
+    },
+
+    /**
+     * Get user and other base info to start prime the cache and the application
+     *
+     * @returns {Promise}
+     */
+    bootstrap: function () {
+        return Api.Users.getById('me')
+            .then(response => {
+                Cache.User.set(response);
+                Tokens.setCurrentName(response.nickname || response.name);
+            });
+    },
+
+    /**
+     * Bootstraps the user from time to time
+     */
+    bootstrapTimer: function () {
+        setTimeout(() => {
+            Api.status()
+                .then(result => {
+                    let version = [result.version.major, result.version.minor, result.version.revision].join('.');
+                    if (version !== this.version) {
+                        document.location.reload();
+                    }
+                })
+                .then(this.bootstrap)
+                .then(() => {
+                    this.bootstrapTimer();
+                })
+                .catch(err => {
+                    if (err.message !== 'timeout' && err.code && err.code !== 400) {
+                        console.log(err);
+                        console.error(err.message);
+                        console.info('Not logged in?');
+                        Controller.showLogin();
+                    } else {
+                        this.bootstrapTimer();
+                    }
+                });
+        }, 30 * 1000); // 30 seconds
+    },
+
+    refreshTokenTimer: function () {
+        setTimeout(() => {
+            return Api.Tokens.refresh()
+                .then(this.bootstrap)
+                .then(() => {
+                    this.refreshTokenTimer();
+                })
+                .catch(err => {
+                    if (err.message !== 'timeout' && err.code && err.code !== 400) {
+                        console.log(err);
+                        console.error(err.message);
+                        console.info('Not logged in?');
+                        Controller.showLogin();
+                    } else {
+                        this.refreshTokenTimer();
+                    }
+                });
+        }, 10 * 60 * 1000);
+    }
+});
+
+const app      = new App();
+module.exports = app;

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

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

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

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

+ 17 - 0
src/frontend/js/app/router.js

@@ -0,0 +1,17 @@
+'use strict';
+
+const Mn         = require('../lib/marionette');
+const Controller = require('./controller');
+
+module.exports = Mn.AppRouter.extend({
+    appRoutes: {
+        users:      'showUsers',
+        profile:    'showProfile',
+        logout:     'logout',
+        '*default': 'showDashboard'
+    },
+
+    initialize: function () {
+        this.controller = Controller;
+    }
+});

+ 128 - 0
src/frontend/js/app/tokens.js

@@ -0,0 +1,128 @@
+'use strict';
+
+const STORAGE_NAME = 'nginx-proxy-manager-tokens';
+
+/**
+ * @returns {Array}
+ */
+const getStorageTokens = function () {
+    let json = window.localStorage.getItem(STORAGE_NAME);
+    if (json) {
+        try {
+            return JSON.parse(json);
+        } catch (err) {
+            return [];
+        }
+    }
+
+    return [];
+};
+
+/**
+ * @param  {Array}  tokens
+ */
+const setStorageTokens = function (tokens) {
+    window.localStorage.setItem(STORAGE_NAME, JSON.stringify(tokens));
+};
+
+const Tokens = {
+
+    /**
+     * @returns {Integer}
+     */
+    getTokenCount: () => {
+        return getStorageTokens().length;
+    },
+
+    /**
+     * @returns {Object}    t,n
+     */
+    getTopToken: () => {
+        let tokens = getStorageTokens();
+        if (tokens && tokens.length) {
+            return tokens[0];
+        }
+
+        return null;
+    },
+
+    /**
+     * @returns {String}
+     */
+    getNextTokenName: () => {
+        let tokens = getStorageTokens();
+        if (tokens && tokens.length > 1 && typeof tokens[1] !== 'undefined' && typeof tokens[1].n !== 'undefined') {
+            return tokens[1].n;
+        }
+
+        return null;
+    },
+
+    /**
+     *
+     * @param   {String}  token
+     * @param   {String}  [name]
+     * @returns {Integer}
+     */
+    addToken: (token, name) => {
+        // Get top token and if it's the same, ignore this call
+        let top = Tokens.getTopToken();
+        if (!top || top.t !== token) {
+            let tokens = getStorageTokens();
+            tokens.unshift({t: token, n: name || null});
+            setStorageTokens(tokens);
+        }
+
+        return Tokens.getTokenCount();
+    },
+
+    /**
+     * @param   {String}  token
+     * @returns {Boolean}
+     */
+    setCurrentToken: token => {
+        let tokens = getStorageTokens();
+        if (tokens.length) {
+            tokens[0].t = token;
+            setStorageTokens(tokens);
+            return true;
+        }
+
+        return false;
+    },
+
+    /**
+     * @param   {String}  name
+     * @returns {Boolean}
+     */
+    setCurrentName: name => {
+        let tokens = getStorageTokens();
+        if (tokens.length) {
+            tokens[0].n = name;
+            setStorageTokens(tokens);
+            return true;
+        }
+
+        return false;
+    },
+
+    /**
+     * @returns {Integer}
+     */
+    dropTopToken: () => {
+        let tokens = getStorageTokens();
+        tokens.shift();
+        setStorageTokens(tokens);
+        return tokens.length;
+    },
+
+    /**
+     *
+     */
+    clearTokens: () => {
+        window.localStorage.removeItem(STORAGE_NAME);
+    }
+
+};
+
+module.exports = Tokens;

+ 14 - 0
src/frontend/js/app/ui/footer/main.ejs

@@ -0,0 +1,14 @@
+<div class="row align-items-center flex-row-reverse">
+    <div class="col-auto ml-auto">
+        <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>
+                </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=docker-registry-ui" target="_blank">jc21.com</a>. Theme by <a href="https://github.com/tabler/tabler?utm_source=docker-registry-ui" target="_blank">Tabler</a>
+    </div>
+</div>

+ 16 - 0
src/frontend/js/app/ui/footer/main.js

@@ -0,0 +1,16 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const template = require('./main.ejs');
+const App      = require('../../main');
+
+module.exports = Mn.View.extend({
+    className: 'container',
+    template:  template,
+
+    templateContext: {
+        getVersion: function () {
+            return App.version;
+        }
+    }
+});

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

@@ -0,0 +1,28 @@
+<div class="container">
+    <div class="d-flex">
+        <a class="navbar-brand" href="/">
+            <img src="/images/favicons/favicon-32x32.png" border="0"> &nbsp; Docker Registry
+        </a>
+
+        <div class="d-flex order-lg-2 ml-auto">
+            <div class="dropdown">
+                <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('name', '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 profile" href="/profile">
+                        <i class="dropdown-icon fe fe-user"></i> Profile
+                    </a>
+                    <div class="dropdown-divider"></div>
+                    <a class="dropdown-item logout" href="/logout">
+                        <i class="dropdown-icon fe fe-log-out"></i> <%- getLogoutText() %>
+                    </a>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>

+ 55 - 0
src/frontend/js/app/ui/header/main.js

@@ -0,0 +1,55 @@
+'use strict';
+
+const $          = require('jquery');
+const Mn         = require('backbone.marionette');
+const Cache      = require('../../cache');
+const Controller = require('../../controller');
+const Tokens     = require('../../tokens');
+const template   = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    id:        'header',
+    className: 'header',
+    template:  template,
+
+    ui: {
+        link: 'a'
+    },
+
+    events: {
+        'click @ui.link': function (e) {
+            e.preventDefault();
+            let href = $(e.currentTarget).attr('href');
+
+            switch (href) {
+                case '/':
+                    Controller.showDashboard();
+                    break;
+                case '/profile':
+                    Controller.showProfile();
+                    break;
+                case '/logout':
+                    Controller.logout();
+                    break;
+            }
+        }
+    },
+
+    templateContext: {
+        getUserField: function (field, default_val) {
+            return Cache.User.get(field) || default_val;
+        },
+
+        getRole: function () {
+            return Cache.User.isAdmin() ? 'Administrator' : 'Apache Helicopter';
+        },
+
+        getLogoutText: function () {
+            if (Tokens.getTokenCount() > 1) {
+                return 'Sign back in as ' + Tokens.getNextTokenName();
+            }
+
+            return 'Sign out';
+        }
+    }
+});

+ 18 - 0
src/frontend/js/app/ui/main.ejs

@@ -0,0 +1,18 @@
+<div class="page-main">
+
+    <div class="header" id="header">
+        <!-- Header View -->
+    </div>
+    <div id="menu">
+        <!-- Menu View -->
+    </div>
+    <div class="my-3 my-md-5">
+        <div id="app-content" class="container">
+            <!-- App View -->
+        </div>
+    </div>
+</div>
+
+<footer class="footer">
+    <!-- Footer View -->
+</footer>

+ 44 - 0
src/frontend/js/app/ui/main.js

@@ -0,0 +1,44 @@
+'use strict';
+
+const Mn         = require('backbone.marionette');
+const template   = require('./main.ejs');
+const HeaderView = require('./header/main');
+const MenuView   = require('./menu/main');
+const FooterView = require('./footer/main');
+const Cache      = require('../cache');
+
+module.exports = Mn.View.extend({
+    className: 'page',
+    template:  template,
+
+    regions: {
+        header_region:      {
+            el:             '#header',
+            replaceElement: true
+        },
+        menu_region:        {
+            el:             '#menu',
+            replaceElement: true
+        },
+        footer_region:      '.footer',
+        app_content_region: '#app-content'
+    },
+
+    showAppContent: function (view) {
+        this.showChildView('app_content_region', view);
+    },
+
+    onRender: function () {
+        this.showChildView('header_region', new HeaderView({
+            model: Cache.User
+        }));
+
+        this.showChildView('menu_region', new MenuView());
+        this.showChildView('footer_region', new FooterView());
+    },
+
+    reset: function () {
+        this.getRegion('header_region').reset();
+        this.getRegion('footer_region').reset();
+    }
+});

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

@@ -0,0 +1,56 @@
+<div class="container">
+    <div class="row align-items-center">
+        <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="../index.html" class="nav-link"><i class="fe fe-home"></i> Home</a>
+                </li>
+                <li class="nav-item">
+                    <a href="javascript:void(0)" class="nav-link" data-toggle="dropdown"><i class="fe fe-box"></i> Interface</a>
+                    <div class="dropdown-menu dropdown-menu-arrow">
+                        <a href="../cards.html" class="dropdown-item ">Cards design</a>
+                        <a href="../charts.html" class="dropdown-item ">Charts</a>
+                        <a href="../pricing-cards.html" class="dropdown-item ">Pricing cards</a>
+                    </div>
+                </li>
+                <li class="nav-item dropdown">
+                    <a href="javascript:void(0)" class="nav-link" data-toggle="dropdown"><i class="fe fe-calendar"></i> Components</a>
+                    <div class="dropdown-menu dropdown-menu-arrow">
+                        <a href="../maps.html" class="dropdown-item ">Maps</a>
+                        <a href="../icons.html" class="dropdown-item ">Icons</a>
+                        <a href="../store.html" class="dropdown-item ">Store</a>
+                        <a href="../blog.html" class="dropdown-item ">Blog</a>
+                        <a href="../carousel.html" class="dropdown-item ">Carousel</a>
+                    </div>
+                </li>
+                <li class="nav-item dropdown">
+                    <a href="javascript:void(0)" class="nav-link" data-toggle="dropdown"><i class="fe fe-file"></i> Pages</a>
+                    <div class="dropdown-menu dropdown-menu-arrow">
+                        <a href="../profile.html" class="dropdown-item ">Profile</a>
+                        <a href="../login.html" class="dropdown-item ">Login</a>
+                        <a href="../register.html" class="dropdown-item ">Register</a>
+                        <a href="../forgot-password.html" class="dropdown-item ">Forgot password</a>
+                        <a href="../400.html" class="dropdown-item ">400 error</a>
+                        <a href="../401.html" class="dropdown-item ">401 error</a>
+                        <a href="../403.html" class="dropdown-item ">403 error</a>
+                        <a href="../404.html" class="dropdown-item ">404 error</a>
+                        <a href="../500.html" class="dropdown-item ">500 error</a>
+                        <a href="../503.html" class="dropdown-item ">503 error</a>
+                        <a href="../email.html" class="dropdown-item ">Email</a>
+                        <a href="../empty.html" class="dropdown-item ">Empty page</a>
+                        <a href="../rtl.html" class="dropdown-item ">RTL mode</a>
+                    </div>
+                </li>
+                <li class="nav-item dropdown">
+                    <a href="../form-elements.html" class="nav-link"><i class="fe fe-check-square"></i> Forms</a>
+                </li>
+                <li class="nav-item">
+                    <a href="../gallery.html" class="nav-link"><i class="fe fe-image"></i> Gallery</a>
+                </li>
+                <li class="nav-item">
+                    <a href="../docs/index.html" class="nav-link"><i class="fe fe-file-text"></i> Documentation</a>
+                </li>
+            </ul>
+        </div>
+    </div>
+</div>

+ 10 - 0
src/frontend/js/app/ui/menu/main.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const Mn       = require('backbone.marionette');
+const template = require('./main.ejs');
+
+module.exports = Mn.View.extend({
+    id:        'menu',
+    className: 'header collapse d-lg-flex p-0',
+    template:  template
+});

+ 112 - 0
src/frontend/js/index.js

@@ -0,0 +1,112 @@
+// This has to exist here so that Webpack picks it up
+import '../scss/styles.scss';
+
+window.tabler = {
+    colors: {
+        'blue':               '#467fcf',
+        'blue-darkest':       '#0e1929',
+        'blue-darker':        '#1c3353',
+        'blue-dark':          '#3866a6',
+        'blue-light':         '#7ea5dd',
+        'blue-lighter':       '#c8d9f1',
+        'blue-lightest':      '#edf2fa',
+        'azure':              '#45aaf2',
+        'azure-darkest':      '#0e2230',
+        'azure-darker':       '#1c4461',
+        'azure-dark':         '#3788c2',
+        'azure-light':        '#7dc4f6',
+        'azure-lighter':      '#c7e6fb',
+        'azure-lightest':     '#ecf7fe',
+        'indigo':             '#6574cd',
+        'indigo-darkest':     '#141729',
+        'indigo-darker':      '#282e52',
+        'indigo-dark':        '#515da4',
+        'indigo-light':       '#939edc',
+        'indigo-lighter':     '#d1d5f0',
+        'indigo-lightest':    '#f0f1fa',
+        'purple':             '#a55eea',
+        'purple-darkest':     '#21132f',
+        'purple-darker':      '#42265e',
+        'purple-dark':        '#844bbb',
+        'purple-light':       '#c08ef0',
+        'purple-lighter':     '#e4cff9',
+        'purple-lightest':    '#f6effd',
+        'pink':               '#f66d9b',
+        'pink-darkest':       '#31161f',
+        'pink-darker':        '#622c3e',
+        'pink-dark':          '#c5577c',
+        'pink-light':         '#f999b9',
+        'pink-lighter':       '#fcd3e1',
+        'pink-lightest':      '#fef0f5',
+        'red':                '#e74c3c',
+        'red-darkest':        '#2e0f0c',
+        'red-darker':         '#5c1e18',
+        'red-dark':           '#b93d30',
+        'red-light':          '#ee8277',
+        'red-lighter':        '#f8c9c5',
+        'red-lightest':       '#fdedec',
+        'orange':             '#fd9644',
+        'orange-darkest':     '#331e0e',
+        'orange-darker':      '#653c1b',
+        'orange-dark':        '#ca7836',
+        'orange-light':       '#feb67c',
+        'orange-lighter':     '#fee0c7',
+        'orange-lightest':    '#fff5ec',
+        'yellow':             '#f1c40f',
+        'yellow-darkest':     '#302703',
+        'yellow-darker':      '#604e06',
+        'yellow-dark':        '#c19d0c',
+        'yellow-light':       '#f5d657',
+        'yellow-lighter':     '#fbedb7',
+        'yellow-lightest':    '#fef9e7',
+        'lime':               '#7bd235',
+        'lime-darkest':       '#192a0b',
+        'lime-darker':        '#315415',
+        'lime-dark':          '#62a82a',
+        'lime-light':         '#a3e072',
+        'lime-lighter':       '#d7f2c2',
+        'lime-lightest':      '#f2fbeb',
+        'green':              '#5eba00',
+        'green-darkest':      '#132500',
+        'green-darker':       '#264a00',
+        'green-dark':         '#4b9500',
+        'green-light':        '#8ecf4d',
+        'green-lighter':      '#cfeab3',
+        'green-lightest':     '#eff8e6',
+        'teal':               '#2bcbba',
+        'teal-darkest':       '#092925',
+        'teal-darker':        '#11514a',
+        'teal-dark':          '#22a295',
+        'teal-light':         '#6bdbcf',
+        'teal-lighter':       '#bfefea',
+        'teal-lightest':      '#eafaf8',
+        'cyan':               '#17a2b8',
+        'cyan-darkest':       '#052025',
+        'cyan-darker':        '#09414a',
+        'cyan-dark':          '#128293',
+        'cyan-light':         '#5dbecd',
+        'cyan-lighter':       '#b9e3ea',
+        'cyan-lightest':      '#e8f6f8',
+        'gray':               '#868e96',
+        'gray-darkest':       '#1b1c1e',
+        'gray-darker':        '#36393c',
+        'gray-light':         '#aab0b6',
+        'gray-lighter':       '#dbdde0',
+        'gray-lightest':      '#f3f4f5',
+        'gray-dark':          '#343a40',
+        'gray-dark-darkest':  '#0a0c0d',
+        'gray-dark-darker':   '#15171a',
+        'gray-dark-dark':     '#2a2e33',
+        'gray-dark-light':    '#717579',
+        'gray-dark-lighter':  '#c2c4c6',
+        'gray-dark-lightest': '#ebebec'
+    }
+};
+
+require('tabler-core');
+
+const App = require('./app/main');
+
+$(document).ready(() => {
+    App.start();
+});

+ 36 - 0
src/frontend/js/lib/helpers.js

@@ -0,0 +1,36 @@
+'use strict';
+
+const numeral = require('numeral');
+const moment  = require('moment');
+
+module.exports = {
+
+    /**
+     * @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>');
+    }
+
+};

+ 117 - 0
src/frontend/js/lib/marionette.js

@@ -0,0 +1,117 @@
+'use strict';
+
+const _       = require('underscore');
+const Mn      = require('backbone.marionette');
+const moment  = require('moment');
+const numeral = require('numeral');
+
+let render = Mn.Renderer.render;
+
+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;
+    };
+
+    /**
+     * @param   {String} date
+     * @returns {String}
+     */
+    data.shortDate = function (date) {
+        let shortdate = '';
+
+        if (typeof date === 'number') {
+            shortdate = moment.unix(date).format('YYYY-MM-DD');
+        } else {
+            shortdate = moment(date).format('YYYY-MM-DD');
+        }
+
+        return moment().format('YYYY-MM-DD') === shortdate ? 'Today' : shortdate;
+    };
+
+    /**
+     * @param   {String} date
+     * @returns {String}
+     */
+    data.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;
+    };
+
+    /**
+     * @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);
+};
+
+module.exports = Mn;

+ 5 - 0
src/frontend/js/login.js

@@ -0,0 +1,5 @@
+const App = require('./login/main');
+
+$(document).ready(() => {
+    App.start();
+});

+ 17 - 0
src/frontend/js/login/main.js

@@ -0,0 +1,17 @@
+'use strict';
+
+const Mn        = require('backbone.marionette');
+const LoginView = require('./ui/login');
+
+const App = Mn.Application.extend({
+
+    region: '#login',
+    UI:     null,
+
+    onStart: function (/*app, options*/) {
+        this.getRegion().show(new LoginView());
+    }
+});
+
+const app      = new App();
+module.exports = app;

+ 28 - 0
src/frontend/js/login/ui/login.ejs

@@ -0,0 +1,28 @@
+<div class="container">
+    <div class="row">
+        <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="form-group">
+                        <label class="form-label">Email address</label>
+                        <input name="identity" type="email" class="form-control" placeholder="Enter email" required>
+                    </div>
+                    <div class="form-group">
+                        <label class="form-label">
+                            Password
+                        </label>
+                        <input name="secret" type="password" class="form-control" placeholder="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>
+                    </div>
+                </div>
+            </form>
+            <div class="text-center text-muted">
+                Nginx Proxy Manager v<%- getVersion() %>
+            </div>
+        </div>
+    </div>
+</div>

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

@@ -0,0 +1,42 @@
+'use strict';
+
+const $        = require('jquery');
+const Mn       = require('backbone.marionette');
+const template = require('./login.ejs');
+const Api      = require('../../app/api');
+
+module.exports = Mn.View.extend({
+    template:  template,
+    className: 'page-single',
+
+    ui: {
+        form:     'form',
+        identity: 'input[name="identity"]',
+        secret:   'input[name="secret"]',
+        error:    '.secret-error',
+        button:   'button'
+    },
+
+    events: {
+        'submit @ui.form': function (e) {
+            e.preventDefault();
+            this.ui.button.addClass('btn-loading').prop('disabled', true);
+            this.ui.error.hide();
+
+            Api.Tokens.login(this.ui.identity.val(), this.ui.secret.val(), true)
+                .then(() => {
+                    window.location = '/';
+                })
+                .catch(err => {
+                    this.ui.error.text(err.message).show();
+                    this.ui.button.removeClass('btn-loading').prop('disabled', false);
+                });
+        }
+    },
+
+    templateContext: {
+        getVersion: function () {
+            return $('#login').data('version');
+        }
+    }
+});

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

@@ -0,0 +1,29 @@
+'use strict';
+
+const _        = require('underscore');
+const Backbone = require('backbone');
+
+const model = Backbone.Model.extend({
+    idAttribute: 'id',
+
+    defaults: function () {
+        return {
+            name:        '',
+            nickname:    '',
+            email:       '',
+            is_disabled: false,
+            roles:       []
+        };
+    },
+
+    isAdmin: function () {
+        return _.indexOf(this.get('roles'), 'admin') !== -1;
+    }
+});
+
+module.exports = {
+    Model:      model,
+    Collection: Backbone.Collection.extend({
+        model: model
+    })
+};

+ 13 - 0
src/frontend/scss/styles.scss

@@ -0,0 +1,13 @@
+@import "~tabler-ui/dist/assets/css/dashboard";
+
+/* Before any JS content is loaded */
+#app > .loader, #login > .loader, .container > .loader {
+    position: absolute;
+    left: 49%;
+    top: 40%;
+    display: block;
+}
+
+.no-js-warning {
+    margin-top: 100px;
+}