diff --git a/src/frontend/app-images/default-avatar.jpg b/src/frontend/app-images/default-avatar.jpg
new file mode 100644
index 0000000..1a0e507
Binary files /dev/null and b/src/frontend/app-images/default-avatar.jpg differ
diff --git a/src/frontend/app-images/favicons/android-chrome-192x192.png b/src/frontend/app-images/favicons/android-chrome-192x192.png
new file mode 100644
index 0000000..7bf92cc
Binary files /dev/null and b/src/frontend/app-images/favicons/android-chrome-192x192.png differ
diff --git a/src/frontend/app-images/favicons/android-chrome-512x512.png b/src/frontend/app-images/favicons/android-chrome-512x512.png
new file mode 100644
index 0000000..499abf5
Binary files /dev/null and b/src/frontend/app-images/favicons/android-chrome-512x512.png differ
diff --git a/src/frontend/app-images/favicons/apple-touch-icon.png b/src/frontend/app-images/favicons/apple-touch-icon.png
new file mode 100644
index 0000000..31e8c9d
Binary files /dev/null and b/src/frontend/app-images/favicons/apple-touch-icon.png differ
diff --git a/src/frontend/app-images/favicons/browserconfig.xml b/src/frontend/app-images/favicons/browserconfig.xml
new file mode 100644
index 0000000..e781b88
--- /dev/null
+++ b/src/frontend/app-images/favicons/browserconfig.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+ #f0ad00
+
+
+
diff --git a/src/frontend/app-images/favicons/favicon-16x16.png b/src/frontend/app-images/favicons/favicon-16x16.png
new file mode 100644
index 0000000..0072a79
Binary files /dev/null and b/src/frontend/app-images/favicons/favicon-16x16.png differ
diff --git a/src/frontend/app-images/favicons/favicon-32x32.png b/src/frontend/app-images/favicons/favicon-32x32.png
new file mode 100644
index 0000000..f8762e7
Binary files /dev/null and b/src/frontend/app-images/favicons/favicon-32x32.png differ
diff --git a/src/frontend/app-images/favicons/favicon.ico b/src/frontend/app-images/favicons/favicon.ico
new file mode 100644
index 0000000..6090f0f
Binary files /dev/null and b/src/frontend/app-images/favicons/favicon.ico differ
diff --git a/src/frontend/app-images/favicons/manifest.json b/src/frontend/app-images/favicons/manifest.json
new file mode 100644
index 0000000..c0a1e16
--- /dev/null
+++ b/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"
+}
\ No newline at end of file
diff --git a/src/frontend/app-images/favicons/mstile-150x150.png b/src/frontend/app-images/favicons/mstile-150x150.png
new file mode 100644
index 0000000..14687cc
Binary files /dev/null and b/src/frontend/app-images/favicons/mstile-150x150.png differ
diff --git a/src/frontend/app-images/favicons/safari-pinned-tab.svg b/src/frontend/app-images/favicons/safari-pinned-tab.svg
new file mode 100644
index 0000000..8333c04
--- /dev/null
+++ b/src/frontend/app-images/favicons/safari-pinned-tab.svg
@@ -0,0 +1,32 @@
+
+
+
diff --git a/src/frontend/fonts b/src/frontend/fonts
new file mode 120000
index 0000000..84b6a8e
--- /dev/null
+++ b/src/frontend/fonts
@@ -0,0 +1 @@
+../../node_modules/tabler-ui/dist/assets/fonts
\ No newline at end of file
diff --git a/src/frontend/images b/src/frontend/images
new file mode 120000
index 0000000..6f1cb6a
--- /dev/null
+++ b/src/frontend/images
@@ -0,0 +1 @@
+../../node_modules/tabler-ui/dist/assets/images
\ No newline at end of file
diff --git a/src/frontend/js/app/api.js b/src/frontend/js/app/api.js
new file mode 100644
index 0000000..d6c4764
--- /dev/null
+++ b/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');
+ }
+ }
+};
diff --git a/src/frontend/js/app/cache.js b/src/frontend/js/app/cache.js
new file mode 100644
index 0000000..c34d674
--- /dev/null
+++ b/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;
+
diff --git a/src/frontend/js/app/controller.js b/src/frontend/js/app/controller.js
new file mode 100644
index 0000000..e217d1d
--- /dev/null
+++ b/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();
+ }
+};
diff --git a/src/frontend/js/app/dashboard/main.ejs b/src/frontend/js/app/dashboard/main.ejs
new file mode 100644
index 0000000..40816a2
--- /dev/null
+++ b/src/frontend/js/app/dashboard/main.ejs
@@ -0,0 +1 @@
+Hi
\ No newline at end of file
diff --git a/src/frontend/js/app/dashboard/main.js b/src/frontend/js/app/dashboard/main.js
new file mode 100644
index 0000000..6694281
--- /dev/null
+++ b/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'
+});
+
diff --git a/src/frontend/js/app/main.js b/src/frontend/js/app/main.js
new file mode 100644
index 0000000..4ef25c6
--- /dev/null
+++ b/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;
diff --git a/src/frontend/js/app/profile/main.ejs b/src/frontend/js/app/profile/main.ejs
new file mode 100644
index 0000000..f1e5d5c
--- /dev/null
+++ b/src/frontend/js/app/profile/main.ejs
@@ -0,0 +1,33 @@
+
\ No newline at end of file
diff --git a/src/frontend/js/app/profile/main.js b/src/frontend/js/app/profile/main.js
new file mode 100644
index 0000000..5f4ea1a
--- /dev/null
+++ b/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;
+ }
+});
+
diff --git a/src/frontend/js/app/router.js b/src/frontend/js/app/router.js
new file mode 100644
index 0000000..91825e7
--- /dev/null
+++ b/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;
+ }
+});
diff --git a/src/frontend/js/app/tokens.js b/src/frontend/js/app/tokens.js
new file mode 100644
index 0000000..fb85e9f
--- /dev/null
+++ b/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;
diff --git a/src/frontend/js/app/ui/footer/main.ejs b/src/frontend/js/app/ui/footer/main.ejs
new file mode 100644
index 0000000..eeea09e
--- /dev/null
+++ b/src/frontend/js/app/ui/footer/main.ejs
@@ -0,0 +1,14 @@
+
diff --git a/src/frontend/js/app/ui/footer/main.js b/src/frontend/js/app/ui/footer/main.js
new file mode 100644
index 0000000..531ad81
--- /dev/null
+++ b/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;
+ }
+ }
+});
diff --git a/src/frontend/js/app/ui/header/main.ejs b/src/frontend/js/app/ui/header/main.ejs
new file mode 100644
index 0000000..eba23e3
--- /dev/null
+++ b/src/frontend/js/app/ui/header/main.ejs
@@ -0,0 +1,28 @@
+
diff --git a/src/frontend/js/app/ui/header/main.js b/src/frontend/js/app/ui/header/main.js
new file mode 100644
index 0000000..74f492f
--- /dev/null
+++ b/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';
+ }
+ }
+});
diff --git a/src/frontend/js/app/ui/main.ejs b/src/frontend/js/app/ui/main.ejs
new file mode 100644
index 0000000..5d8b656
--- /dev/null
+++ b/src/frontend/js/app/ui/main.ejs
@@ -0,0 +1,18 @@
+
+
+
\ No newline at end of file
diff --git a/src/frontend/js/app/ui/main.js b/src/frontend/js/app/ui/main.js
new file mode 100644
index 0000000..b013cd4
--- /dev/null
+++ b/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();
+ }
+});
diff --git a/src/frontend/js/app/ui/menu/main.ejs b/src/frontend/js/app/ui/menu/main.ejs
new file mode 100644
index 0000000..d4b975c
--- /dev/null
+++ b/src/frontend/js/app/ui/menu/main.ejs
@@ -0,0 +1,56 @@
+
\ No newline at end of file
diff --git a/src/frontend/js/app/ui/menu/main.js b/src/frontend/js/app/ui/menu/main.js
new file mode 100644
index 0000000..b9fdf17
--- /dev/null
+++ b/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
+});
diff --git a/src/frontend/js/index.js b/src/frontend/js/index.js
new file mode 100644
index 0000000..bfaa017
--- /dev/null
+++ b/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();
+});
diff --git a/src/frontend/js/lib/helpers.js b/src/frontend/js/lib/helpers.js
new file mode 100644
index 0000000..984b513
--- /dev/null
+++ b/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, '$2');
+ }
+
+};
diff --git a/src/frontend/js/lib/marionette.js b/src/frontend/js/lib/marionette.js
new file mode 100644
index 0000000..810defe
--- /dev/null
+++ b/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 = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ '\'': ''',
+ '/': '/'
+ };
+
+ 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;
diff --git a/src/frontend/js/login.js b/src/frontend/js/login.js
new file mode 100644
index 0000000..0094e2a
--- /dev/null
+++ b/src/frontend/js/login.js
@@ -0,0 +1,5 @@
+const App = require('./login/main');
+
+$(document).ready(() => {
+ App.start();
+});
diff --git a/src/frontend/js/login/main.js b/src/frontend/js/login/main.js
new file mode 100644
index 0000000..80d2866
--- /dev/null
+++ b/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;
diff --git a/src/frontend/js/login/ui/login.ejs b/src/frontend/js/login/ui/login.ejs
new file mode 100644
index 0000000..991d42f
--- /dev/null
+++ b/src/frontend/js/login/ui/login.ejs
@@ -0,0 +1,28 @@
+
+
+
+
+
+ Nginx Proxy Manager v<%- getVersion() %>
+
+
+
+
diff --git a/src/frontend/js/login/ui/login.js b/src/frontend/js/login/ui/login.js
new file mode 100644
index 0000000..07ee8eb
--- /dev/null
+++ b/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');
+ }
+ }
+});
diff --git a/src/frontend/js/models/user.js b/src/frontend/js/models/user.js
new file mode 100644
index 0000000..41250d3
--- /dev/null
+++ b/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
+ })
+};
diff --git a/src/frontend/scss/styles.scss b/src/frontend/scss/styles.scss
new file mode 100644
index 0000000..ac3bf72
--- /dev/null
+++ b/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;
+}
\ No newline at end of file