diff --git a/Makefile b/Makefile index 2d1d104..2aab89f 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,11 @@ WWWCSSDIR=${WWWBASEDIR}/css WWWIMAGESDIR=${WWWBASEDIR}/images WWWJSDIR=${WWWBASEDIR}/js -IMAGES= \ - logo-128.png +IMAGES= \ + logo-128.png \ + proxmox_logo.png + +CSSFILES = ext6-pmg.css all: @@ -25,12 +28,17 @@ deb ${DEB}: cd build; dpkg-buildpackage -b -us -uc lintian ${DEB} -install: index.html +js/pmgmanagerlib.js: + make -C js pmgmanagerlib.js + +install: index.html js/pmgmanagerlib.js install -d -m 755 ${WWWCSSDIR} install -d -m 755 ${WWWIMAGESDIR} install -d -m 755 ${WWWJSDIR} install -m 0644 index.html ${WWWBASEDIR} - for i in ${IMAGES}; do install -m 0644 images/$$i ${WWWIMAGESDIR}; done + install -m 0644 js/pmgmanagerlib.js ${WWWJSDIR} + for i in ${IMAGES}; do install -m 0644 images/$$i ${WWWIMAGESDIR}; done + for i in ${CSSFILES}; do install -m 0644 css/$$i ${WWWCSSDIR}; done .PHONY: upload upload: ${DEB} @@ -40,7 +48,8 @@ distclean: clean rm -f examples/simple-demo.pem clean: - rm -rf ./build *.deb *.changes + make -C js clean + rm -rf ./build *.deb *.changes *.buildinfo find . -name '*~' -exec rm {} ';' .PHONY: dinstall diff --git a/css/ext6-pmg.css b/css/ext6-pmg.css new file mode 100644 index 0000000..46d674a --- /dev/null +++ b/css/ext6-pmg.css @@ -0,0 +1,117 @@ +/* overwrite to use full black for enabled buttons */ +.x-btn-inner-default-toolbar-small { + font: 300 12px/16px helvetica, arial, verdana, sans-serif; + color: #000; + padding: 0 5px; + max-width: 100%; +} + +/* add missing class for context menu header */ +.x-menu-header { + font: 400 13px/20px 'Open Sans', 'Helvetica Neue', helvetica, arial, verdana, sans-serif; + color: #fff; + padding: 4px; + background-color: #3892d4; +} + +/* make the upper window end visible */ +.x-css-shadow { + box-shadow: rgb(136,136,136) 0px -1px 15px !important; +} + +/* reduce tree space */ +.x-grid-cell-inner-treecolumn { /* vertical padding */ + padding: 4px 0px 3px 0px; +} + +/* horizontal distance between parent and child leaf */ +.x-tree-elbow-img { + width: 14px; +} + +/* adjust horizontal position of menu icons */ +.x-menu-item-icon-default { + top: 5px; + left: 3px; + font-size: 14px; +} + +/* this gives a better placement for the font-awesome icons */ +.x-btn-icon-el-default-toolbar-small { + height: 14px; +} + +/* this icon looks weird in sizes not n*14px */ +.x-btn-icon-el-default-toolbar-small.fa-ellipsis-v { + font-size: 14px; +} + +.x-btn-icon-el-default-small { + height: 14px; + font-size: 14px; +} + +/* icons for tree/snapshots/menus/etc.. */ + +/* overwrite folder icons of theme */ +.x-tree-icon-parent, +.x-tree-icon-parent-expanded { + background: none; + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 1.25em; + line-height: 1.6em; + color: #555; + margin-right: 5px; +} + +.x-tree-icon-parent:not(.x-tree-icon-custom):before { + content: "\f114"; +} + +.x-tree-icon-parent-expanded:not(.x-tree-icon-custom):before { + content: "\f115"; +} + +/* loading in task list */ +.x-grid-row-loading { + background: no-repeat center center; + background-image:url(../ext6/theme-crisp/resources/images/loadmask/loading.gif); +} + +/* displayfield minheight is wrong */ +.x-form-display-field-default { + min-height: 20px; +} + +.x-button-reset:before { + font-size: 16px; +} + +/* for resetcolumnsbutton */ +.x-button-reset:after{ + content: "\f0e7 "; + position: relative; + text-shadow: 0 0 2px #fff; + left: -3px; + top: 2px; +} + +/* for auto layout */ +div.inline-block { + display: inline-block; + vertical-align: top; +} + +.pointer { + cursor: pointer; +} + +.x-grid-filters-filtered-column{ + font-style: italic; + font-weight: bold; +} diff --git a/images/proxmox_logo.png b/images/proxmox_logo.png new file mode 100644 index 0000000..4f99b8c Binary files /dev/null and b/images/proxmox_logo.png differ diff --git a/index.html b/index.html index d145626..42e3f4a 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ - + [% IF debug %] diff --git a/js/LoginWindow.js b/js/LoginWindow.js new file mode 100644 index 0000000..4e74ba5 --- /dev/null +++ b/js/LoginWindow.js @@ -0,0 +1,131 @@ +Ext.define('PMG.window.LoginWindow', { + extend: 'Ext.window.Window', + + controller: { + + xclass: 'Ext.app.ViewController', + + onLogon: function() { + var me = this; + + var form = this.lookupReference('loginForm'); + var unField = this.lookupReference('usernameField'); + var view = this.getView(); + + if(form.isValid()){ + view.el.mask(gettext('Please wait...'), 'x-mask-loading'); + + form.submit({ + failure: function(f, resp){ + view.el.unmask(); + var handler = function() { + var uf = me.lookupReference('usernameField'); + uf.focus(true, true); + }; + + Ext.MessageBox.alert(gettext('Error'), + gettext("Login failed. Please try again"), + handler); + }, + success: function(f, resp){ + view.el.unmask(); + + var handler = view.handler || Ext.emptyFn; + handler.call(me, resp.result.data); + view.close(); + } + }); + } + }, + + control: { + 'field[name=username]': { + specialkey: function(f, e) { + if (e.getKey() === e.ENTER) { + var pf = this.lookupReference('passwordField'); + if (pf.getValue()) { + this.onLogon(); + } else { + pf.focus(false); + } + } + } + }, + 'field[name=password]': { + specialkey: function(f, e) { + if (e.getKey() === e.ENTER) { + this.onLogon(); + } + } + }, + 'button[reference=loginButton]': { + click: 'onLogon' + }, + '#': { + show: function() { + var unField = this.lookupReference('usernameField'); + unField.focus(); + } + } + } + }, + + width: 400, + + modal: true, + + border: false, + + draggable: true, + + closable: false, + + resizable: false, + + layout: 'auto', + + title: gettext('Proxmox Mail Gateway Login'), + + defaultFocus: 'usernameField', + + items: [{ + xtype: 'form', + layout: 'form', + url: '/api2/extjs/access/ticket', + reference: 'loginForm', + + fieldDefaults: { + labelAlign: 'right', + allowBlank: false + }, + + items: [ + { + xtype: 'textfield', + fieldLabel: gettext('User name'), + name: 'username', + itemId: 'usernameField', + reference: 'usernameField', + stateId: 'login-username' + }, + { + xtype: 'textfield', + inputType: 'password', + fieldLabel: gettext('Password'), + name: 'password', + reference: 'passwordField' + }, + { + xtype: 'hiddenfield', + name: 'realm', + value: 'pam', + } + ], + buttons: [ + { + text: gettext('Login'), + reference: 'loginButton' + } + ] + }] + }); diff --git a/js/Makefile b/js/Makefile new file mode 100644 index 0000000..41ffbaa --- /dev/null +++ b/js/Makefile @@ -0,0 +1,21 @@ +JSSRC= \ + Utils.js \ + LoginWindow.js \ + Workspace.js + +lint: ${JSSRC} + jslint ${JSSRC} + +pmgmanagerlib.js: ${JSSRC} + cat ${JSSRC} >$@.tmp + mv $@.tmp $@ + +all: pmgmanagerlib.js + +.PHONY: clean +clean: + find . -name '*~' -exec rm {} ';' + rm -rf pmgmanagerlib.js + + + diff --git a/js/Utils.js b/js/Utils.js new file mode 100644 index 0000000..bb25f7c --- /dev/null +++ b/js/Utils.js @@ -0,0 +1,194 @@ +Ext.ns('PMG'); + +// TODO: implement gettext +function gettext(buf) { return buf; } + +// avoid errors related to Accessible Rich Internet Applications +// (access for people with disabilities) +// TODO reenable after all components are upgraded +Ext.enableAria = false; +Ext.enableAriaButtons = false; +Ext.enableAriaPanels = false; + +// avoid errors when running without development tools +if (!Ext.isDefined(Ext.global.console)) { + var console = { + dir: function() {}, + log: function() {} + }; +} +console.log("Starting PMG Manager"); + +Ext.Ajax.defaultHeaders = { + 'Accept': 'application/json' +}; + +Ext.Ajax.on('beforerequest', function(conn, options) { + if (PMG.CSRFPreventionToken) { + if (!options.headers) { + options.headers = {}; + } + options.headers.CSRFPreventionToken = PMG.CSRFPreventionToken; + } +}); + +Ext.define('PMG.Utils', { utilities: { + + // this singleton contains miscellaneous utilities + + authOK: function() { + return Ext.util.Cookies.get('PMGAuthCookie'); + }, + + authClear: function() { + Ext.util.Cookies.clear("PMGAuthCookie"); + }, + + // comp.setLoading() is buggy in ExtJS 4.0.7, so we + // use el.mask() instead + setErrorMask: function(comp, msg) { + var el = comp.el; + if (!el) { + return; + } + if (!msg) { + el.unmask(); + } else { + if (msg === true) { + el.mask(gettext("Loading...")); + } else { + el.mask(msg); + } + } + }, + + extractRequestError: function(result, verbose) { + var msg = gettext('Successful'); + + if (!result.success) { + msg = gettext("Unknown error"); + if (result.message) { + msg = result.message; + if (result.status) { + msg += ' (' + result.status + ')'; + } + } + if (verbose && Ext.isObject(result.errors)) { + msg += "
"; + Ext.Object.each(result.errors, function(prop, desc) { + msg += "
" + Ext.htmlEncode(prop) + ": " + + Ext.htmlEncode(desc); + }); + } + } + + return msg; + }, + + // Ext.Ajax.request + API2Request: function(reqOpts) { + + var newopts = Ext.apply({ + waitMsg: gettext('Please wait...') + }, reqOpts); + + if (!newopts.url.match(/^\/api2/)) { + newopts.url = '/api2/extjs' + newopts.url; + } + delete newopts.callback; + + var createWrapper = function(successFn, callbackFn, failureFn) { + Ext.apply(newopts, { + success: function(response, options) { + if (options.waitMsgTarget) { + options.waitMsgTarget.setLoading(false); + } + var result = Ext.decode(response.responseText); + response.result = result; + if (!result.success) { + response.htmlStatus = PMG.Utils.extractRequestError(result, true); + Ext.callback(callbackFn, options.scope, [options, false, response]); + Ext.callback(failureFn, options.scope, [response, options]); + return; + } + Ext.callback(callbackFn, options.scope, [options, true, response]); + Ext.callback(successFn, options.scope, [response, options]); + }, + failure: function(response, options) { + if (options.waitMsgTarget) { + options.waitMsgTarget.setLoading(false); + } + response.result = {}; + try { + response.result = Ext.decode(response.responseText); + } catch(e) {} + var msg = gettext('Connection error') + ' - server offline?'; + if (response.aborted) { + msg = gettext('Connection error') + ' - aborted.'; + } else if (response.timedout) { + msg = gettext('Connection error') + ' - Timeout.'; + } else if (response.status && response.statusText) { + msg = gettext('Connection error') + ' ' + response.status + ': ' + response.statusText; + } + response.htmlStatus = msg; + Ext.callback(callbackFn, options.scope, [options, false, response]); + Ext.callback(failureFn, options.scope, [response, options]); + } + }); + }; + + createWrapper(reqOpts.success, reqOpts.callback, reqOpts.failure); + + var target = newopts.waitMsgTarget; + if (target) { + // Note: ExtJS bug - this does not work when component is not rendered + target.setLoading(newopts.waitMsg); + } + Ext.Ajax.request(newopts); + }, + + yesText: gettext('Yes'), + noText: gettext('No') + + }, + + singleton: true, + constructor: function() { + var me = this; + Ext.apply(me, me.utilities); + + var IPV4_OCTET = "(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])"; + var IPV4_REGEXP = "(?:(?:" + IPV4_OCTET + "\\.){3}" + IPV4_OCTET + ")"; + var IPV6_H16 = "(?:[0-9a-fA-F]{1,4})"; + var IPV6_LS32 = "(?:(?:" + IPV6_H16 + ":" + IPV6_H16 + ")|" + IPV4_REGEXP + ")"; + + + me.IP4_match = new RegExp("^(?:" + IPV4_REGEXP + ")$"); + me.IP4_cidr_match = new RegExp("^(?:" + IPV4_REGEXP + ")\/([0-9]{1,2})$"); + + var IPV6_REGEXP = "(?:" + + "(?:(?:" + "(?:" + IPV6_H16 + ":){6})" + IPV6_LS32 + ")|" + + "(?:(?:" + "::" + "(?:" + IPV6_H16 + ":){5})" + IPV6_LS32 + ")|" + + "(?:(?:(?:" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){4})" + IPV6_LS32 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,1}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){3})" + IPV6_LS32 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,2}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){2})" + IPV6_LS32 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,3}" + IPV6_H16 + ")?::" + "(?:" + IPV6_H16 + ":){1})" + IPV6_LS32 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,4}" + IPV6_H16 + ")?::" + ")" + IPV6_LS32 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,5}" + IPV6_H16 + ")?::" + ")" + IPV6_H16 + ")|" + + "(?:(?:(?:(?:" + IPV6_H16 + ":){0,7}" + IPV6_H16 + ")?::" + ")" + ")" + + ")"; + + me.IP6_match = new RegExp("^(?:" + IPV6_REGEXP + ")$"); + me.IP6_cidr_match = new RegExp("^(?:" + IPV6_REGEXP + ")\/([0-9]{1,3})$"); + me.IP6_bracket_match = new RegExp("^\\[(" + IPV6_REGEXP + ")\\]"); + + me.IP64_match = new RegExp("^(?:" + IPV6_REGEXP + "|" + IPV4_REGEXP + ")$"); + + var DnsName_REGEXP = "(?:(([a-zA-Z0-9]([a-zA-Z0-9\\-]*[a-zA-Z0-9])?)\\.)*([A-Za-z0-9]([A-Za-z0-9\\-]*[A-Za-z0-9])?))"; + me.DnsName_match = new RegExp("^" + DnsName_REGEXP + "$"); + + me.HostPort_match = new RegExp("^(" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")(:\\d+)?$"); + me.HostPortBrackets_match = new RegExp("^\\[(?:" + IPV6_REGEXP + "|" + IPV4_REGEXP + "|" + DnsName_REGEXP + ")\\](:\\d+)?$"); + me.IP6_dotnotation_match = new RegExp("^" + IPV6_REGEXP + "(\\.\\d+)?$"); + } +}); diff --git a/js/Workspace.js b/js/Workspace.js new file mode 100644 index 0000000..fa74bd3 --- /dev/null +++ b/js/Workspace.js @@ -0,0 +1,288 @@ +/* + * Workspace base class + * + * popup login window when auth fails (call onLogin handler) + * update (re-login) ticket every 15 minutes + * + */ + +Ext.define('PMG.Workspace', { + extend: 'Ext.container.Viewport', + + title: 'Proxmox Mail Gateway', + + loginData: null, // Data from last login call + + onLogin: function(loginData) {}, + + // private + updateLoginData: function(loginData) { + var me = this; + + console.dir(loginData); + + me.loginData = loginData; + PMG.CSRFPreventionToken = loginData.CSRFPreventionToken; + PMG.UserName = loginData.username; + + // creates a session cookie (expire = null) + // that way the cookie gets deleted after browser window close + Ext.util.Cookies.set('PMGAuthCookie', loginData.ticket, null, '/', null, true); + me.onLogin(loginData); + }, + + // private + showLogin: function() { + var me = this; + + PMG.Utils.authClear(); + PMG.UserName = null; + me.loginData = null; + + if (!me.login) { + me.login = Ext.create('PMG.window.LoginWindow', { + handler: function(data) { + me.login = null; + me.updateLoginData(data); + } + }); + } + me.onLogin(null); + me.login.show(); + }, + + initComponent : function() { + var me = this; + + Ext.tip.QuickTipManager.init(); + + // fixme: what about other errors + Ext.Ajax.on('requestexception', function(conn, response, options) { + if (response.status == 401) { // auth failure + me.showLogin(); + } + }); + + me.callParent(); + + if (!PMG.Utils.authOK()) { + me.showLogin(); + } else { + if (me.loginData) { + me.onLogin(me.loginData); + } + } + + Ext.TaskManager.start({ + run: function() { + var ticket = PMG.Utils.authOK(); + if (!ticket || !PMG.UserName) { + return; + } + + Ext.Ajax.request({ + params: { + username: PMG.UserName, + password: ticket + }, + url: '/api2/json/access/ticket', + method: 'POST', + success: function(response, opts) { + var obj = Ext.decode(response.responseText); + me.updateLoginData(obj.data); + } + }); + }, + interval: 15*60*1000 + }); + } +}); + +Ext.define('PMG.StdWorkspace', { + extend: 'PMG.Workspace', + + alias: ['widget.pmgStdWorkspace'], + + // private + setContent: function(comp) { + var me = this; + + var cont = me.child('#content'); + + var lay = cont.getLayout(); + + var cur = lay.getActiveItem(); + + if (comp) { + PMG.Utils.setErrorMask(cont, false); + comp.border = false; + cont.add(comp); + if (cur !== null && lay.getNext()) { + lay.next(); + var task = Ext.create('Ext.util.DelayedTask', function(){ + cont.remove(cur); + }); + task.delay(10); + } + } + else { + // helper for cleaning the content when logging out + cont.removeAll(); + } + }, + + onLogin: function(loginData) { + var me = this; + + me.updateUserInfo(); + + if (loginData) { + PMG.Utils.API2Request({ + url: '/version', + method: 'GET', + success: function(response) { + PMG.VersionInfo = response.result.data; + me.updateVersionInfo(); + } + }); + } + }, + + updateUserInfo: function() { + var me = this; + + var ui = me.query('#userinfo')[0]; + + if (PMG.UserName) { + var msg = Ext.String.format(gettext("You are logged in as {0}"), "'" + PMG.UserName + "'"); + ui.update('
' + msg + '
'); + } else { + ui.update(''); + } + ui.updateLayout(); + }, + + updateVersionInfo: function() { + var me = this; + + var ui = me.query('#versioninfo')[0]; + + if (PMG.VersionInfo) { + var version = PMG.VersionInfo.version + '-' + PMG.VersionInfo.release + '/' + + PMG.VersionInfo.repoid; + ui.update('Mail Gateway ' + version); + } else { + ui.update('Mail Gateway'); + } + ui.updateLayout(); + }, + + initComponent : function() { + var me = this; + + Ext.History.init(); + + // var sprovider = Ext.create('PVE.StateProvider'); + // Ext.state.Manager.setProvider(sprovider); + + Ext.apply(me, { + layout: { type: 'border' }, + border: false, + items: [ + { + region: 'north', + layout: { + type: 'hbox', + align: 'middle' + }, + baseCls: 'x-plain', + defaults: { + baseCls: 'x-plain' + }, + border: false, + margin: '2 0 2 5', + items: [ + { + html: '' + + '' + }, + { + minWidth: 200, + id: 'versioninfo', + html: 'Mail Gateway' + }, + { + flex: 1 + }, + { + pack: 'end', + id: 'userinfo', + stateful: false + }, + { + pack: 'end', + margin: '0 5 0 10', + xtype: 'button', + baseCls: 'x-btn', + iconCls: 'fa fa-sign-out', + text: gettext("Logout"), + handler: function() { + me.showLogin(); + me.setContent(null); + } + } + ] + }, + { + region: 'center', + stateful: true, + stateId: 'pvecenter', + minWidth: 100, + minHeight: 100, + id: 'content', + xtype: 'container', + layout: { type: 'card' }, + border: false, + margin: '0 5 0 0', + items: [] + }, + { + region: 'west', + stateful: true, + stateId: 'pvewest', + itemId: 'west', + xtype: 'container', + border: false, + layout: { type: 'vbox', align: 'stretch' }, + margin: '0 0 0 5', + split: true, + width: 200, + items: [{ html: "A TEST" }], + listeners: { + resize: function(panel, width, height) { + var viewWidth = me.getSize().width; + if (width > viewWidth - 100) { + panel.setWidth(viewWidth - 100); + } + } + } + } + ] + }); + + me.callParent(); + + me.updateUserInfo(); + + // on resize, center all modal windows + Ext.on('resize', function(){ + var wins = Ext.ComponentQuery.query('window[modal]'); + if (wins.length > 0) { + wins.forEach(function(win){ + win.alignTo(me, 'c-c'); + }); + } + }); + } +}); +