diff --git a/admin/inc/class.admin_customfields.inc.php b/admin/inc/class.admin_customfields.inc.php index 2cc4b3eb1d..74f8c0cc4c 100644 --- a/admin/inc/class.admin_customfields.inc.php +++ b/admin/inc/class.admin_customfields.inc.php @@ -82,7 +82,8 @@ class admin_customfields 'search' => 'set get_rows, get_title and id_field, or use @path to read options from a file in EGroupware directory', 'select' => 'each value is a line like id[=label], or use @path to read options from a file in EGroupware directory', 'radio' => 'each value is a line like id[=label], or use @path to read options from a file in EGroupware directory', - 'button' => 'each value is a line like label=[javascript]' + 'button' => 'each value is a line like label=[javascript]', + 'password'=> 'set length=# for minimum password length, strength=# for password strength' ); /** @@ -92,6 +93,7 @@ class admin_customfields public static $type_attribute_flags = array( 'text' => array('cf_len' => true, 'cf_rows' => true), 'float' => array('cf_len' => true), + 'passwd'=> array('cf_len' => true, 'cf_rows' => false, 'cf_values' => true), 'label' => array('cf_values' => true), 'select' => array('cf_len' => false, 'cf_rows' => true, 'cf_values' => true), 'date' => array('cf_len' => true, 'cf_rows' => false, 'cf_values' => true), diff --git a/api/js/etemplate/et2_extension_customfields.js b/api/js/etemplate/et2_extension_customfields.js index 9581e0744b..d7564c7032 100644 --- a/api/js/etemplate/et2_extension_customfields.js +++ b/api/js/etemplate/et2_extension_customfields.js @@ -354,6 +354,12 @@ var et2_customfields_list = /** @class */ (function (_super) { } return true; }; + et2_customfields_list.prototype._setup_passwd = function (field_name, field, attrs) { + // No label on the widget itself + delete (attrs.label); + attrs['viewable'] = true; + return true; + }; et2_customfields_list.prototype._setup_ajax_select = function (field_name, field, attrs) { var attributes = ['get_rows', 'get_title', 'id_field', 'template']; if (field.values) { diff --git a/api/js/etemplate/et2_extension_customfields.ts b/api/js/etemplate/et2_extension_customfields.ts index 34a24f2436..4c7a050b84 100644 --- a/api/js/etemplate/et2_extension_customfields.ts +++ b/api/js/etemplate/et2_extension_customfields.ts @@ -480,6 +480,14 @@ export class et2_customfields_list extends et2_valueWidget implements et2_IDetac } return true; } + _setup_passwd( field_name, field, attrs) + { + // No label on the widget itself + delete (attrs.label); + attrs['viewable'] = true; + return true; + } + _setup_ajax_select( field_name, field, attrs) { const attributes = ['get_rows', 'get_title', 'id_field', 'template']; diff --git a/api/js/etemplate/et2_widget_password.ts b/api/js/etemplate/et2_widget_password.ts new file mode 100644 index 0000000000..f7c7827f0a --- /dev/null +++ b/api/js/etemplate/et2_widget_password.ts @@ -0,0 +1,307 @@ +/** + * EGroupware eTemplate2 - JS Textbox object + * + * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License + * @package etemplate + * @subpackage api + * @link http://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_valueWidget} from './et2_core_valueWidget' +import {et2_inputWidget} from './et2_core_inputWidget' +import {et2_button} from './et2_widget_button' +import {et2_textbox} from "./et2_widget_textbox"; +import {et2_dialog} from "./et2_widget_dialog"; + +/** + * 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" + }, + "suggest": { + name: "Suggest password", + type: "integer", + default: 16, + 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 || {})); + + } + + 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(length) + { + jQuery('.suggest', this.wrapper).show(); + } + else + { + jQuery('.suggest', 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 == et2_dialog.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 = et2_dialog.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,true,this + ).sendRequest(); + } + + destroy() + { + super.destroy(); + } + + getValue() + { + return this.input.val(); + } +} +et2_register_widget(et2_password, [ "passwd"]); diff --git a/api/js/etemplate/et2_widget_textbox.js b/api/js/etemplate/et2_widget_textbox.js index 7078437cdb..853424aa15 100644 --- a/api/js/etemplate/et2_widget_textbox.js +++ b/api/js/etemplate/et2_widget_textbox.js @@ -64,16 +64,6 @@ var et2_textbox = /** @class */ (function (_super) { else { this.input = jQuery(document.createElement("input")); switch (this.options.type) { - case "passwd": - this.input.attr("type", "password"); - // Make autocomplete default value off for password field - // seems browsers not respecting 'off' anymore and started to - // impelement 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"; - break; case "hidden": this.input.attr("type", "hidden"); break; @@ -102,21 +92,6 @@ var et2_textbox = /** @class */ (function (_super) { }); } }; - /** - * Override the parent set_id method to manuipulate the input DOM node - * - * @param {type} _value - * @returns {undefined} - */ - et2_textbox.prototype.set_id = function (_value) { - _super.prototype.set_id.call(this, _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.type === "passwd" - && this.options.autocomplete === "off") - this.input.removeAttr('name'); - }; et2_textbox.prototype.destroy = function () { var node = this.getInputNode(); if (node) @@ -268,12 +243,6 @@ var et2_textbox = /** @class */ (function (_super) { "default": et2_no_init, "description": "Perl regular expression eg. '/^[0-9][a-f]{4}$/i'" }, - "autocomplete": { - "name": "Autocomplete", - "type": "string", - "default": "", - "description": "Weither or not browser should autocomplete that field: 'on', 'off', 'default' (use attribute from form). Default value for type password is set to off." - }, onkeypress: { name: "onKeypress", type: "js", @@ -285,7 +254,7 @@ var et2_textbox = /** @class */ (function (_super) { return et2_textbox; }(et2_core_inputWidget_1.et2_inputWidget)); exports.et2_textbox = et2_textbox; -et2_core_widget_1.et2_register_widget(et2_textbox, ["textbox", "passwd", "hidden"]); +et2_core_widget_1.et2_register_widget(et2_textbox, ["textbox", "hidden"]); /** * et2_textbox_ro is the dummy readonly implementation of the textbox. * diff --git a/api/js/etemplate/et2_widget_textbox.ts b/api/js/etemplate/et2_widget_textbox.ts index 82821e97dc..76388327f5 100644 --- a/api/js/etemplate/et2_widget_textbox.ts +++ b/api/js/etemplate/et2_widget_textbox.ts @@ -72,12 +72,6 @@ export class et2_textbox extends et2_inputWidget implements et2_IResizeable "default": et2_no_init, "description": "Perl regular expression eg. '/^[0-9][a-f]{4}$/i'" }, - "autocomplete": { - "name": "Autocomplete", - "type": "string", - "default": "", - "description": "Weither or not browser should autocomplete that field: 'on', 'off', 'default' (use attribute from form). Default value for type password is set to off." - }, onkeypress: { name: "onKeypress", type: "js", @@ -125,15 +119,6 @@ export class et2_textbox extends et2_inputWidget implements et2_IResizeable this.input = jQuery(document.createElement("input")); switch(this.options.type) { - case "passwd": - this.input.attr("type", "password"); - // Make autocomplete default value off for password field - // seems browsers not respecting 'off' anymore and started to - // impelement 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"; - break; case "hidden": this.input.attr("type", "hidden"); break; @@ -166,23 +151,6 @@ export class et2_textbox extends et2_inputWidget implements et2_IResizeable } } - /** - * 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.type === "passwd" - && this.options.autocomplete === "off") this.input.removeAttr('name'); - } - destroy() { var node = this.getInputNode(); @@ -312,7 +280,7 @@ export class et2_textbox extends et2_inputWidget implements et2_IResizeable } } } -et2_register_widget(et2_textbox, ["textbox", "passwd", "hidden"]); +et2_register_widget(et2_textbox, ["textbox", "hidden"]); /** * et2_textbox_ro is the dummy readonly implementation of the textbox. diff --git a/api/js/etemplate/etemplate2.js b/api/js/etemplate/etemplate2.js index c7d1a8cd9c..ed20f628eb 100644 --- a/api/js/etemplate/etemplate2.js +++ b/api/js/etemplate/etemplate2.js @@ -25,6 +25,7 @@ et2_widget_entry; et2_widget_textbox; et2_widget_number; + et2_widget_password; et2_widget_url; et2_widget_selectbox; et2_widget_checkbox; diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index 9a7d3366d5..f53a1db445 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -24,6 +24,7 @@ et2_widget_entry; et2_widget_textbox; et2_widget_number; + et2_widget_password; et2_widget_url; et2_widget_selectbox; et2_widget_checkbox; diff --git a/api/src/Etemplate/Widget/Customfields.php b/api/src/Etemplate/Widget/Customfields.php index e295f2c59b..f7774413d0 100644 --- a/api/src/Etemplate/Widget/Customfields.php +++ b/api/src/Etemplate/Widget/Customfields.php @@ -32,6 +32,7 @@ class Customfields extends Transformer */ protected static $cf_types = array( 'text' => 'Text', + 'passwd' => 'Password', 'int' => 'Integer', 'float' => 'Float', 'label' => 'Label', @@ -322,6 +323,9 @@ class Customfields extends Transformer case 'text': break; + case 'passwd': + $widget->attrs['viewable'] = true; + break; default: if (substr($type, 0, 7) !== 'select-' && $type != 'ajax_select') break; diff --git a/api/src/Etemplate/Widget/Password.php b/api/src/Etemplate/Widget/Password.php new file mode 100644 index 0000000000..ecbe69557b --- /dev/null +++ b/api/src/Etemplate/Widget/Password.php @@ -0,0 +1,142 @@ + + * @copyright 2002-16 by RalfBecker@outdoor-training.de + * @version $Id$ + */ + +namespace EGroupware\Api\Etemplate\Widget; + +use EGroupware\Api\Etemplate; +use EGroupware\Api\Auth; +use EGroupware\Api\Mail\Credentials; +use XMLReader; + +/** + * eTemplate password widget + * + * passwords are not sent to client, instead a number of asterisks is send and replaced again! + * + * User must authenticate before password is decrypted & sent + */ +class Password extends Etemplate\Widget\Textbox +{ + /** + * Constructor + * + * @param string|XMLReader $xml string with xml or XMLReader positioned on the element to construct + * @throws Api\Exception\WrongParameter + */ + public function __construct($xml) + { + parent::__construct($xml); + } + + /** + * Set up what we know on the server side. + * + * @param string $cname + * @param array $expand values for keys 'c', 'row', 'c_', 'row_', 'cont' + */ + public function beforeSendToClient($cname, array $expand=null) + { + $form_name = self::form_name($cname, $this->id, $expand); + $value =& self::get_array(self::$request->content, $form_name); + if (!empty($value)) + { + $preserv =& self::get_array(self::$request->preserv, $form_name, true); + $preserv = (string)$value; + + if (!empty($value) && array_key_exists('viewable', $this->attrs) && $this->attrs['viewable'] == 'false') + { + $value = str_repeat('*', strlen($preserv)); + } + //$value = str_repeat('*', strlen($preserv)); + } + } + + /** + * Validate input + * + * We check if the password is unchanged or if the new value is the decrypted + * version of the current value to avoid unneeded changes + * + * @param string $cname current namespace + * @param array $expand values for keys 'c', 'row', 'c_', 'row_', 'cont' + * @param array $content + * @param array &$validated=array() validated content + * @param array $expand=array values for keys 'c', 'row', 'c_', 'row_', 'cont' + */ + public function validate($cname, array $expand, array $content, &$validated=array()) + { + $form_name = self::form_name($cname, $this->id, $expand); + + if (!$this->is_readonly($cname, $form_name)) + { + $value = $value_in = self::get_array($content, $form_name); + + // Non-viewable passwords are not transmitted back to client (just asterisks) + // therefore we need to replace it again with preserved value + $preserv = self::get_array(self::$request->preserv, $form_name); + if ($value == str_repeat('*', strlen($preserv))) + { + $value = $preserv; + } + else if ($value_in == Credentials::decrypt(array('cred_password' => $preserv,'cred_pw_enc' => Credentials::SYSTEM_AES))) + { + // Don't change if they submitted the decrypted version + $value = $preserv; + } + else if ($value_in !== $preserv) + { + // Store encrypted + $encryption = null; + $value = Credentials::encrypt($value_in, 0, $encryption); + } + + if ((string)$value === '' && $this->attrs['needed']) + { + self::set_validation_error($form_name,lang('Field must not be empty !!!'),''); + } + + if (isset($value)) + { + self::set_array($validated, $form_name, $value); + //error_log(__METHOD__."() $form_name: ".array2string($value_in).' --> '.array2string($value)); + } + } + } + + /** + * Suggest a password + */ + public static function ajax_suggest($size = 12) + { + $password = Auth::randomstring($size, false); + + $response = \EGroupware\Api\Json\Response::get(); + $response->data($password); + } + + /** + * Give up the password + */ + public static function ajax_decrypt($user_password, $password) + { + $response = \EGroupware\Api\Json\Response::get(); + $decrypted = ''; + + if($GLOBALS['egw']->auth->authenticate($GLOBALS['egw_info']['user']['account_lid'],$user_password)) + { + $decrypted = Credentials::decrypt(array('cred_password' => $password,'cred_pw_enc' => Credentials::SYSTEM_AES)); + } + $response->data($decrypted); + } +} +Etemplate\Widget::registerWidget(__NAMESPACE__.'\\Password', array('passwd')); diff --git a/api/src/Etemplate/Widget/Textbox.php b/api/src/Etemplate/Widget/Textbox.php index de542899f3..023fb25c7a 100644 --- a/api/src/Etemplate/Widget/Textbox.php +++ b/api/src/Etemplate/Widget/Textbox.php @@ -23,7 +23,6 @@ use XMLReader; * - float * - hidden * - colorpicker - * - passwd (passwords are never send back to client, instead a number of asterisks is send and replaced again!) * sub-types are either passed to constructor or set via 'type' attribute! */ class Textbox extends Etemplate\Widget @@ -82,18 +81,7 @@ class Textbox extends Etemplate\Widget */ public function beforeSendToClient($cname, array $expand=null) { - // to NOT transmit passwords back to client, we need to store (non-empty) value in preserv - if ($this->attrs['type'] == 'passwd' || $this->type == 'passwd') - { - $form_name = self::form_name($cname, $this->id, $expand); - $value =& self::get_array(self::$request->content, $form_name); - if (!empty($value)) - { - $preserv =& self::get_array(self::$request->preserv, $form_name, true); - if (true) $preserv = (string)$value; - $value = str_repeat('*', strlen($preserv)); - } - } + } /** @@ -137,17 +125,6 @@ class Textbox extends Etemplate\Widget $value = $value_in = self::get_array($content, $form_name); - // passwords are not transmitted back to client (just asterisks) - // therefore we need to replace it again with preserved value - if (($this->attrs['type'] == 'passwd' || $this->type == 'passwd')) - { - $preserv = self::get_array(self::$request->preserv, $form_name); - if ($value == str_repeat('*', strlen($preserv))) - { - $value = $preserv; - } - } - if ((string)$value === '' && $this->attrs['needed']) { self::set_validation_error($form_name,lang('Field must not be empty !!!'),''); @@ -199,4 +176,4 @@ class Textbox extends Etemplate\Widget } } } -Etemplate\Widget::registerWidget(__NAMESPACE__.'\\Textbox', array('textbox','text','int','integer','float','passwd','hidden','colorpicker','hidden')); +Etemplate\Widget::registerWidget(__NAMESPACE__.'\\Textbox', array('textbox','text','int','integer','float','hidden','colorpicker','hidden')); diff --git a/api/src/Mail/Credentials.php b/api/src/Mail/Credentials.php index 52dc4d415c..8b3e25deb3 100644 --- a/api/src/Mail/Credentials.php +++ b/api/src/Mail/Credentials.php @@ -393,7 +393,7 @@ class Credentials * @param int& $pw_enc on return encryption used * @return string encrypted password */ - protected static function encrypt($password, $account_id, &$pw_enc) + public static function encrypt($password, $account_id, &$pw_enc) { try { return self::encrypt_openssl_aes($password, $account_id, $pw_enc); @@ -524,7 +524,7 @@ class Credentials * @throws Api\Exception\WrongParameter * @throws Api\Exception\AssertionFailed if neither OpenSSL nor MCrypt extension available */ - protected static function decrypt(array $row, $key=null) + public static function decrypt(array $row, $key=null) { // empty/unset passwords only give warnings ... if (empty($row['cred_password'])) return ''; diff --git a/api/templates/default/etemplate2.css b/api/templates/default/etemplate2.css index e0eaddbe21..f1484989cd 100644 --- a/api/templates/default/etemplate2.css +++ b/api/templates/default/etemplate2.css @@ -97,6 +97,30 @@ div.et2_hbox > div { background: inherit; } +/** + * Password widget + */ +.et2_password { + display: inline-block; + border: 1px solid #e6e6e6; + padding-right: 14px; + white-space: nowrap; +} +.et2_password > input { + border: none; + margin-right: -23px; + padding-right: 10px; +} +.et2_password .show_hide { + margin-left: -5px; +} +.et2_password > * { + vertical-align: middle; +} +.et2_password > img { + margin: 3px; +} + /** * Placeholder widget - used for un-implemented widgets */