From 888b518e9bfc772cc774fa37cfc45cfe20501045 Mon Sep 17 00:00:00 2001 From: ralf Date: Fri, 22 Jul 2022 15:21:27 +0200 Subject: [PATCH] implement et2-password web-component replacing passwd legacy widget enabled Et2InvokerMixin to use an image instead of a textual label also remove et2_fullWidth CSS class from all web-components in the preprocessor --- api/etemplate.php | 9 + api/js/etemplate/Et2Textbox/Et2Password.ts | 174 +++++++++++ api/js/etemplate/Et2Url/Et2InvokerMixin.ts | 20 +- api/js/etemplate/Et2Url/Et2Url.ts | 2 + api/js/etemplate/Et2Url/Et2UrlEmail.ts | 2 + api/js/etemplate/Et2Url/Et2UrlFax.ts | 2 + api/js/etemplate/Et2Url/Et2UrlPhone.ts | 1 + api/js/etemplate/et2_widget_password.ts | 343 --------------------- api/js/etemplate/etemplate2.ts | 2 +- 9 files changed, 210 insertions(+), 345 deletions(-) create mode 100644 api/js/etemplate/Et2Textbox/Et2Password.ts delete mode 100644 api/js/etemplate/et2_widget_password.ts diff --git a/api/etemplate.php b/api/etemplate.php index bb9a045c64..151625c0be 100644 --- a/api/etemplate.php +++ b/api/etemplate.php @@ -270,6 +270,8 @@ function send_template() return $replace; }, $str); + $str = preg_replace('#]+)/>#', '', $str); + // ^^^^^^^^^^^^^^^^ above widgets get transformed independent of legacy="true" set in overlay ^^^^^^^^^^^^^^^^^^ // eTemplate marked as legacy --> replace only some widgets (eg. requiring jQueryUI) with web-components @@ -368,6 +370,13 @@ function send_template() unset($attrs[$name]); } } + + // remove no longer necessary et2_fullWidth class, it's the default now anyway + if (isset($attrs['class']) && empty($attrs['class'] = trim(preg_replace('/(^| )et2_fullWidth( |$)/', ' ', $attrs['class'])))) + { + unset($attrs['class']); + } + $ret = str_replace($matches[3], implode(' ', array_map(static function ($name, $value) { return $name . '="' . $value . '"'; }, array_keys($attrs), $attrs)).(substr($matches[3], -1) === '/' ? '/' : ''), $matches[0]); diff --git a/api/js/etemplate/Et2Textbox/Et2Password.ts b/api/js/etemplate/Et2Textbox/Et2Password.ts new file mode 100644 index 0000000000..753fcab6c9 --- /dev/null +++ b/api/js/etemplate/Et2Textbox/Et2Password.ts @@ -0,0 +1,174 @@ +/** + * EGroupware eTemplate2 - Password input widget + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package api + * @link https://www.egroupware.org + * @author Ralf Becker + */ + +/* eslint-disable import/no-extraneous-dependencies */ +import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin"; +import {Et2Textbox} from "./Et2Textbox"; +import {Et2Dialog} from "../Et2Dialog/Et2Dialog"; + +/** + * @customElement et2-password + */ +export class Et2Password extends Et2InvokerMixin(Et2Textbox) +{ + // The password is stored encrypted server side, and passed encrypted. + // This flag is for if we've decrypted the password to show it already + private encrypted = true; + private visible = false; + + /** @type {any} */ + static get properties() + { + return { + ...super.properties, + /** + * Password is plaintext + */ + plaintext: Boolean, + /** + * Suggest password length (0 for off) + */ + suggest: Number, + }; + } + + constructor() + { + super(); + + this.plaintext = true; + this.suggest = 0; + + this._invokerLabel = ''; + this._invokerTitle = this.egw().lang("Suggest password"); + this._invokerAction = () => + { + this.suggestPassword(); + }; + } + + transformAttributes(attrs) + { + attrs.suggest = parseInt(attrs.suggest); + attrs.type = 'password'; + + if (attrs.viewable) + { + attrs['toggle-password'] = true; + } + + super.transformAttributes(attrs); + } + + /** + * Method to check if invoker can be activated: not disabled, empty or invalid + * + * @protected + * */ + _toggleInvokerDisabled() + { + if (this._invokerNode) + { + const invokerNode = /** @type {HTMLElement & {disabled: boolean}} */ (this._invokerNode); + invokerNode.disabled = this.disabled; + } + } + + /** + * @param {PropertyKey} name + * @param {?} oldValue + */ + requestUpdate(name, oldValue) + { + super.requestUpdate(name, oldValue); + + if (name === 'suggest' && this.suggest != oldValue) + { + this._invokerLabel = this.suggest ? 'generate_password' : ''; + this._toggleInvokerDisabled(); + } + } + + /** + * Ask the server for a password suggestion + */ + suggestPassword() + { + // They need to see the suggestion + this.encrypted = false; + this.type = 'text'; + //this.toggle_visibility(true); + + let suggestion = "Suggestion"; + let request = egw.request("EGroupware\\Api\\Etemplate\\Widget\\Password::ajax_suggest", [this.suggest]) + .then(suggestion => + { + this.encrypted = false; + this.value = suggestion; + + // Check for second password, update it too + let two = this.getParent().getWidgetById(this.id+'_2'); + if(two && two.getType() == this.getType()) + { + two.type = 'text'; + two.value = suggestion; + } + }); + } + + /** + * If the password is viewable, toggle the visibility. + * If the password is still encrypted, we'll ask for the user's password then have the server decrypt it. + */ + handlePasswordToggle() + { + super.handlePasswordToggle(); + + this.visible = !this.visible; // can't access private isPasswordVisible + + if (!this.visible || !this.encrypted) + { + return; + } + + // Need username & password to decrypt + Et2Dialog.show_prompt( + (button, user_password) => + { + if(button == Et2Dialog.CANCEL_BUTTON) + { + return this.handlePasswordToggle(); + } + this.egw().request( + "EGroupware\\Api\\Etemplate\\Widget\\Password::ajax_decrypt", + [user_password, this.value]).then(decrypted => + { + if (decrypted) + { + this.encrypted = false; + this.value = decrypted; + this.type = 'text'; + } + else + { + this.set_validation_error(this.egw().lang("invalid password")); + window.setTimeout(() => + { + this.set_validation_error(false); + }, 2000); + } + }); + }, + this.egw().lang("Enter your password"), + this.egw().lang("Authenticate") + ); + } +} +// @ts-ignore TypeScript is not recognizing that this is a LitElement +customElements.define("et2-password", Et2Password); \ No newline at end of file diff --git a/api/js/etemplate/Et2Url/Et2InvokerMixin.ts b/api/js/etemplate/Et2Url/Et2InvokerMixin.ts index b64ceef640..e4fee61d2c 100644 --- a/api/js/etemplate/Et2Url/Et2InvokerMixin.ts +++ b/api/js/etemplate/Et2Url/Et2InvokerMixin.ts @@ -30,6 +30,9 @@ export const Et2InvokerMixin = dedupeMixin(>(s static get properties() { return { + /** + * Textual label or image specifier for egw.image() + */ _invokerLabel: { type: String, }, @@ -65,6 +68,11 @@ export const Et2InvokerMixin = dedupeMixin(>(s width: 14px; border: none !important; background-color: transparent !important; + width: 1em; + height: 1em; + background-position: center right; + background-size: contain; + background-repeat: no-repeat; } ::slotted(:disabled) {cursor: default !important;} :host(:hover) ::slotted([slot="suffix"]) { @@ -142,7 +150,17 @@ export const Et2InvokerMixin = dedupeMixin(>(s if (this._invokerNode) { this._invokerNode.style.display = !this._invokerLabel ? 'none' : 'inline-block'; - this._invokerNode.innerHTML = this._invokerLabel || ''; + const img = this._invokerLabel ? this.egw().image(this._invokerLabel) : null; + if (img) + { + this._invokerNode.style.backgroundImage = 'url('+img+')'; + this._invokerNode.innerHTML = ''; + } + else + { + this._invokerNode.style.backgroundImage = 'none'; + this._invokerNode.innerHTML = this._invokerLabel || ''; + } this._invokerNode.title = this._invokerTitle || ''; } } diff --git a/api/js/etemplate/Et2Url/Et2Url.ts b/api/js/etemplate/Et2Url/Et2Url.ts index f72cd93dc5..29dcf65b6d 100644 --- a/api/js/etemplate/Et2Url/Et2Url.ts +++ b/api/js/etemplate/Et2Url/Et2Url.ts @@ -49,6 +49,8 @@ export class Et2Url extends Et2InvokerMixin(Et2Textbox) ::slotted([slot="suffix"]) { font-size: 133% !important; position: relative; + height: auto; + width: auto; } `, ]; diff --git a/api/js/etemplate/Et2Url/Et2UrlEmail.ts b/api/js/etemplate/Et2Url/Et2UrlEmail.ts index 6968b76004..93cd753851 100644 --- a/api/js/etemplate/Et2Url/Et2UrlEmail.ts +++ b/api/js/etemplate/Et2Url/Et2UrlEmail.ts @@ -27,6 +27,8 @@ export class Et2UrlEmail extends Et2InvokerMixin(Et2Textbox) css` ::slotted([slot="suffix"]) { font-size: 90% !important; + height: auto; + width: auto; } `, ]; diff --git a/api/js/etemplate/Et2Url/Et2UrlFax.ts b/api/js/etemplate/Et2Url/Et2UrlFax.ts index 83a9b54786..1bb7f58c3d 100644 --- a/api/js/etemplate/Et2Url/Et2UrlFax.ts +++ b/api/js/etemplate/Et2Url/Et2UrlFax.ts @@ -27,6 +27,8 @@ export class Et2UrlFax extends Et2UrlPhone ::slotted([slot="suffix"]) { font-size: 90% !important; position: relative; + height: auto; + width: auto; } `, ]; diff --git a/api/js/etemplate/Et2Url/Et2UrlPhone.ts b/api/js/etemplate/Et2Url/Et2UrlPhone.ts index ccdacb6e54..74b286ca2d 100644 --- a/api/js/etemplate/Et2Url/Et2UrlPhone.ts +++ b/api/js/etemplate/Et2Url/Et2UrlPhone.ts @@ -26,6 +26,7 @@ export class Et2UrlPhone extends Et2InvokerMixin(Et2Textbox) css` ::slotted([slot="suffix"]) { font-size: 133% !important; + height: auto; } `, ]; diff --git a/api/js/etemplate/et2_widget_password.ts b/api/js/etemplate/et2_widget_password.ts deleted file mode 100644 index 4a2391daf2..0000000000 --- a/api/js/etemplate/et2_widget_password.ts +++ /dev/null @@ -1,343 +0,0 @@ -/** - * EGroupware eTemplate2 - JS Textbox object - * - * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License - * @package etemplate - * @subpackage api - * @link https://www.egroupware.org - * @author Andreas Stöckel - */ - -/*egw:uses - /vendor/bower-asset/jquery/dist/jquery.js; - et2_core_inputWidget; - et2_core_valueWidget; -*/ - -import './et2_core_common'; -import {ClassWithAttributes} from "./et2_core_inheritance"; -import {et2_createWidget, et2_register_widget, WidgetConfig} from "./et2_core_widget"; -import {et2_inputWidget} from './et2_core_inputWidget' -import {et2_button} from './et2_widget_button' -import {et2_textbox, et2_textbox_ro} from "./et2_widget_textbox"; -import {egw} from "../jsapi/egw_global"; -import {Et2Dialog} from "./Et2Dialog/Et2Dialog"; - -/** - * Class which implements the "textbox" XET-Tag - * - * @augments et2_inputWidget - */ -export class et2_password extends et2_textbox -{ - static readonly _attributes : any = { - "autocomplete": { - "name": "Autocomplete", - "type": "string", - "default": "off", - "description": "Whether or not browser should autocomplete that field: 'on', 'off', 'default' (use attribute from form). Default value is set to off." - }, - "viewable": { - "name": "Viewable", - "type": "boolean", - "default": false, - "description": "Allow password to be shown" - }, - "plaintext": { - name: "Plaintext", - type: "boolean", - default: true, - description: "Password is plaintext" - }, - "suggest": { - name: "Suggest password", - type: "integer", - default: 0, - description: "Suggest password length (0 for off)" - } - }; - - public static readonly DEFAULT_LENGTH = 16; - wrapper : JQuery; - private suggest_button: et2_button; - private show_button: et2_button; - - // The password is stored encrypted server side, and passed encrypted. - // This flag is for if we've decrypted the password to show it already - private encrypted : boolean = true; - - /** - * Constructor - */ - constructor(_parent, _attrs? : WidgetConfig, _child? : object) - { - // Call the inherited constructor - super(_parent, _attrs, ClassWithAttributes.extendAttributes(et2_password._attributes, _child || {})); - - if(this.options.plaintext) - { - this.encrypted = false; - } - } - - createInputWidget() - { - this.wrapper = jQuery(document.createElement("div")) - .addClass("et2_password"); - this.input = jQuery(document.createElement("input")) - - this.input.attr("type", "password"); - // Make autocomplete default value off for password field - // seems browsers not respecting 'off' anymore and started to - // implement a new key called "new-password" considered as switching - // autocomplete off. - // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion - if (this.options.autocomplete === "" || this.options.autocomplete == "off") this.options.autocomplete = "new-password"; - - if(this.options.size) { - this.set_size(this.options.size); - } - if(this.options.blur) { - this.set_blur(this.options.blur); - } - if(this.options.readonly) { - this.set_readonly(true); - } - this.input.addClass("et2_textbox") - .appendTo(this.wrapper); - this.setDOMNode(this.wrapper[0]); - if(this.options.value) - { - this.set_value(this.options.value); - } - if (this.options.onkeypress && typeof this.options.onkeypress == 'function') - { - var self = this; - this.input.on('keypress', function(_ev) - { - return self.options.onkeypress.call(this, _ev, self); - }); - } - this.input.on('change', function() { - this.encrypted = false; - }.bind(this)); - - // Show button is needed from start as you can't turn viewable on via JS - let attrs = { - class: "show_hide", - image: "visibility", - onclick: this.toggle_visibility.bind(this), - statustext: this.egw().lang("Show password") - }; - if(this.options.viewable) - { - this.show_button = et2_createWidget("button", attrs, this); - } - } - - getInputNode() - { - return this.input[0]; - } - - /** - * Override the parent set_id method to manuipulate the input DOM node - * - * @param {type} _value - * @returns {undefined} - */ - set_id(_value) - { - super.set_id(_value); - - // Remove the name attribute inorder to affect autocomplete="off" - // for no password save. ATM seems all browsers ignore autocomplete for - // input field inside the form - if (this.options.autocomplete === "off") this.input.removeAttr('name'); - } - - /** - * Set whether or not the password is allowed to be shown in clear text. - * - * @param viewable - */ - set_viewable(viewable: boolean) - { - this.options.viewable = viewable; - - if(viewable) - { - jQuery('.show_hide', this.wrapper).show(); - } - else - { - jQuery('.show_hide', this.wrapper).hide(); - } - } - - /** - * Turn on or off the suggest password button. - * - * When clicked, a password of the set length will be generated. - * - * @param length Length of password to generate. 0 to disable. - */ - set_suggest(length: number) - { - if(typeof length !== "number") - { - length = typeof length === "string" ? parseInt(length) : (length ? et2_password.DEFAULT_LENGTH : 0); - } - this.options.suggest = length; - - if(length && !this.suggest_button) - { - let attrs = { - class: "generate_password", - image: "generate_password", - onclick: this.suggest_password.bind(this), - statustext: this.egw().lang("Suggest password") - }; - this.suggest_button = et2_createWidget("button", attrs, this); - if(this.parentNode) - { - // Turned on after initial load, need to run loadingFinished() - this.suggest_button.loadingFinished(); - } - } - if(length) - { - jQuery('.generate_password', this.wrapper).show(); - } - else - { - jQuery('.generate_password', this.wrapper).hide(); - } - } - - /** - * If the password is viewable, toggle the visibility. - * If the password is still encrypted, we'll ask for the user's password then have the server decrypt it. - * - * @param on - */ - toggle_visibility(on : boolean | undefined) - { - if(typeof on !== "boolean") - { - on = this.input.attr("type") == "password"; - } - if(!this.options.viewable) - { - this.input.attr("type", "password"); - return; - } - if(this.show_button) - { - this.show_button.set_image(this.egw().image(on ? 'visibility_off' : 'visibility')); - } - - // If we are not encrypted or not showing it, we're done - if(!this.encrypted || !on) - { - this.input.attr("type",on ? "textbox" : "password"); - return; - } - - - // Need username & password to decrypt - let callback = function(button, user_password) - { - if(button == Et2Dialog.CANCEL_BUTTON) - { - return this.toggle_visibility(false); - } - let request = egw.json( - "EGroupware\\Api\\Etemplate\\Widget\\Password::ajax_decrypt", - [user_password, this.options.value], - function(decrypted) - { - if(decrypted) - { - this.encrypted = false; - this.input.val(decrypted); - this.input.attr("type", "textbox"); - } - else - { - this.set_validation_error(this.egw().lang("invalid password")); - window.setTimeout(function() - { - this.set_validation_error(false); - }.bind(this), 2000); - } - }, - this, true, this - ).sendRequest(); - }.bind(this); - let prompt = Et2Dialog.show_prompt( - callback, - this.egw().lang("Enter your password"), - this.egw().lang("Authenticate") - ); - - // Make the password prompt a password field - prompt.div.on("load", function() { - jQuery(prompt.template.widgetContainer.getWidgetById('value').getInputNode()) - .attr("type","password"); - }); - - } - - /** - * Ask the server for a password suggestion - */ - suggest_password() - { - // They need to see the suggestion - this.encrypted = false; - this.options.viewable = true; - this.toggle_visibility(true); - - let suggestion = "Suggestion"; - let request = egw.json("EGroupware\\Api\\Etemplate\\Widget\\Password::ajax_suggest", - [this.options.suggest], - function(suggestion) { - this.encrypted = false; - this.input.val(suggestion); - this.input.trigger('change'); - - // Check for second password, update it too - let two = this.getParent().getWidgetById(this.id+'_2'); - if(two && two.getType() == this.getType()) - { - two.options.viewable = true; - two.toggle_visibility(true); - two.set_value(suggestion); - } - }, - this,true,this - ).sendRequest(); - } - - destroy() - { - super.destroy(); - } - - getValue() - { - return this.input.val(); - } -} -et2_register_widget(et2_password, [ "passwd"]); - - -export class et2_password_ro extends et2_textbox_ro -{ - set_value(value) - { - this.value_span.text(value ? "********" : ""); - } -} -et2_register_widget(et2_password_ro, [ "passwd_ro"]); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 1ec3d3ed7e..c7efe5b5cb 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -85,6 +85,7 @@ import "./Layout/Et2Split/Et2Split"; import "./Layout/RowLimitedMixin"; import "./Et2Vfs/Et2VfsMime"; import "./Et2Vfs/Et2VfsUid"; +import "./Et2Textbox/Et2Password"; /* Include all widget classes here, we only care about them registering, not importing anything*/ import './et2_widget_vfs'; // Vfs must be first (before et2_widget_file) due to import cycle @@ -98,7 +99,6 @@ import './et2_widget_color'; import './et2_widget_entry'; import './et2_widget_textbox'; import './et2_widget_number'; -import './et2_widget_password'; import './et2_widget_url'; import './et2_widget_selectbox'; import './et2_widget_checkbox';