From 4950edc1964cf375a50eca67bb4046ffbf0afb28 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Wed, 8 Jul 2020 16:26:38 +0800 Subject: [PATCH] Add sensitive field input (#5201) A field widget that allows for entering of sensitive information that can be revealed at the user's request - ie. API keys, secrets. When a sensitive field that has been previously populated is loaded again, a placeholder is used instead of the real value, until the user opts to reveal the value. The real value is loaded via AJAX. Credit to @tomaszstrojny for the original implementation. Replaces #5062. Fixes #5061, #1850, perhaps #1061. Co-authored-by: Tomasz Strojny Co-authored-by: Luke Towers --- modules/backend/ServiceProvider.php | 2 + modules/backend/formwidgets/Sensitive.php | 117 +++++++++++ .../sensitive/assets/css/sensitive.css | 2 + .../sensitive/assets/js/sensitive.js | 192 ++++++++++++++++++ .../sensitive/assets/less/sensitive.less | 10 + .../sensitive/partials/_sensitive.htm | 41 ++++ modules/system/models/mailsetting/fields.yaml | 5 + 7 files changed, 369 insertions(+) create mode 100644 modules/backend/formwidgets/Sensitive.php create mode 100644 modules/backend/formwidgets/sensitive/assets/css/sensitive.css create mode 100644 modules/backend/formwidgets/sensitive/assets/js/sensitive.js create mode 100644 modules/backend/formwidgets/sensitive/assets/less/sensitive.less create mode 100644 modules/backend/formwidgets/sensitive/partials/_sensitive.htm diff --git a/modules/backend/ServiceProvider.php b/modules/backend/ServiceProvider.php index b0a5026f81..4c3825dd02 100644 --- a/modules/backend/ServiceProvider.php +++ b/modules/backend/ServiceProvider.php @@ -80,6 +80,7 @@ protected function registerAssetBundles() $combiner->registerBundle('~/modules/backend/formwidgets/colorpicker/assets/less/colorpicker.less'); $combiner->registerBundle('~/modules/backend/formwidgets/permissioneditor/assets/less/permissioneditor.less'); $combiner->registerBundle('~/modules/backend/formwidgets/markdowneditor/assets/less/markdowneditor.less'); + $combiner->registerBundle('~/modules/backend/formwidgets/sensitive/assets/less/sensitive.less'); /* * Rich Editor is protected by DRM @@ -199,6 +200,7 @@ protected function registerBackendWidgets() $manager->registerFormWidget('Backend\FormWidgets\TagList', 'taglist'); $manager->registerFormWidget('Backend\FormWidgets\MediaFinder', 'mediafinder'); $manager->registerFormWidget('Backend\FormWidgets\NestedForm', 'nestedform'); + $manager->registerFormWidget('Backend\FormWidgets\Sensitive', 'sensitive'); }); } diff --git a/modules/backend/formwidgets/Sensitive.php b/modules/backend/formwidgets/Sensitive.php new file mode 100644 index 0000000000..a28a8d60b9 --- /dev/null +++ b/modules/backend/formwidgets/Sensitive.php @@ -0,0 +1,117 @@ +fillFromConfig([ + 'readOnly', + 'disabled', + 'allowCopy', + 'hiddenPlaceholder', + 'hideOnTabChange', + ]); + + if ($this->formField->disabled || $this->formField->readOnly) { + $this->previewMode = true; + } + } + + /** + * @inheritDoc + */ + public function render() + { + $this->prepareVars(); + + return $this->makePartial('sensitive'); + } + + /** + * Prepares the view data for the widget partial. + */ + public function prepareVars() + { + $this->vars['readOnly'] = $this->readOnly; + $this->vars['disabled'] = $this->disabled; + $this->vars['hasValue'] = !empty($this->getLoadValue()); + $this->vars['allowCopy'] = $this->allowCopy; + $this->vars['hiddenPlaceholder'] = $this->hiddenPlaceholder; + $this->vars['hideOnTabChange'] = $this->hideOnTabChange; + } + + /** + * Reveals the value of a hidden, unmodified sensitive field. + * + * @return array + */ + public function onShowValue() + { + return [ + 'value' => $this->getLoadValue() + ]; + } + + /** + * @inheritDoc + */ + public function getSaveValue($value) + { + if ($value === $this->hiddenPlaceholder) { + $value = $this->getLoadValue(); + } + + return $value; + } + + /** + * @inheritDoc + */ + protected function loadAssets() + { + $this->addCss('css/sensitive.css', 'core'); + $this->addJs('js/sensitive.js', 'core'); + } +} diff --git a/modules/backend/formwidgets/sensitive/assets/css/sensitive.css b/modules/backend/formwidgets/sensitive/assets/css/sensitive.css new file mode 100644 index 0000000000..c8ac9378a9 --- /dev/null +++ b/modules/backend/formwidgets/sensitive/assets/css/sensitive.css @@ -0,0 +1,2 @@ +div[data-control="sensitive"] a[data-toggle], +div[data-control="sensitive"] a[data-copy] {box-shadow:none;border:1px solid #d1d6d9;border-left:0} \ No newline at end of file diff --git a/modules/backend/formwidgets/sensitive/assets/js/sensitive.js b/modules/backend/formwidgets/sensitive/assets/js/sensitive.js new file mode 100644 index 0000000000..69251304cd --- /dev/null +++ b/modules/backend/formwidgets/sensitive/assets/js/sensitive.js @@ -0,0 +1,192 @@ +/* + * Sensitive field widget plugin. + * + * Data attributes: + * - data-control="sensitive" - enables the plugin on an element + * + * JavaScript API: + * $('div#someElement').sensitive({...}) + */ ++function ($) { "use strict"; + var Base = $.oc.foundation.base, + BaseProto = Base.prototype + + var Sensitive = function(element, options) { + this.$el = $(element) + this.options = options + this.clean = Boolean(this.$el.data('clean')) + this.hidden = true + + this.$input = this.$el.find('[data-input]').first() + this.$toggle = this.$el.find('[data-toggle]').first() + this.$icon = this.$el.find('[data-icon]').first() + this.$loader = this.$el.find('[data-loader]').first() + this.$copy = this.$el.find('[data-copy]').first() + + $.oc.foundation.controlUtils.markDisposable(element) + Base.call(this) + this.init() + } + + Sensitive.DEFAULTS = { + readOnly: false, + disabled: false, + eventHandler: null, + hideOnTabChange: false, + } + + Sensitive.prototype = Object.create(BaseProto) + Sensitive.prototype.constructor = Sensitive + + Sensitive.prototype.init = function() { + this.$input.on('keydown', this.proxy(this.onInput)) + this.$toggle.on('click', this.proxy(this.onToggle)) + + if (this.options.hideOnTabChange) { + // Watch for tab change or minimise + document.addEventListener('visibilitychange', this.proxy(this.onTabChange)) + } + + if (this.$copy.length) { + this.$copy.on('click', this.proxy(this.onCopy)) + } + } + + Sensitive.prototype.dispose = function () { + this.$input.off('keydown', this.proxy(this.onInput)) + this.$toggle.off('click', this.proxy(this.onToggle)) + + if (this.options.hideOnTabChange) { + document.removeEventListener('visibilitychange', this.proxy(this.onTabChange)) + } + + if (this.$copy.length) { + this.$copy.off('click', this.proxy(this.onCopy)) + } + + this.$input = this.$toggle = this.$icon = this.$loader = null + this.$el = null + + BaseProto.dispose.call(this) + } + + Sensitive.prototype.onInput = function() { + if (this.clean) { + this.clean = false + this.$input.val('') + } + + return true + } + + Sensitive.prototype.onToggle = function() { + if (this.$input.val() !== '' && this.clean) { + this.reveal() + } else { + this.toggleVisibility() + } + + return true + } + + Sensitive.prototype.onTabChange = function() { + if (document.hidden && !this.hidden) { + this.toggleVisibility() + } + } + + Sensitive.prototype.onCopy = function() { + var that = this, + deferred = $.Deferred(), + isHidden = this.hidden + + deferred.then(function () { + if (that.hidden) { + that.toggleVisibility() + } + + that.$input.focus() + that.$input.select() + + try { + document.execCommand('copy') + } catch (err) { + } + + that.$input.blur() + if (isHidden) { + that.toggleVisibility() + } + }) + + if (this.$input.val() !== '' && this.clean) { + this.reveal(deferred) + } else { + deferred.resolve() + } + } + + Sensitive.prototype.toggleVisibility = function() { + if (this.hidden) { + this.$input.attr('type', 'text') + } else { + this.$input.attr('type', 'password') + } + + this.$icon.toggleClass('icon-eye icon-eye-slash') + + this.hidden = !this.hidden + } + + Sensitive.prototype.reveal = function(deferred) { + var that = this + this.$icon.css({ + visibility: 'hidden' + }) + this.$loader.removeClass('hide') + + this.$input.request(this.options.eventHandler, { + success: function (data) { + that.$input.val(data.value) + that.clean = false + + that.$icon.css({ + visibility: 'visible' + }) + that.$loader.addClass('hide') + + that.toggleVisibility() + + if (deferred) { + deferred.resolve() + } + } + }) + } + + var old = $.fn.sensitive + + $.fn.sensitive = function (option) { + var args = Array.prototype.slice.call(arguments, 1), result + this.each(function () { + var $this = $(this) + var data = $this.data('oc.sensitive') + var options = $.extend({}, Sensitive.DEFAULTS, $this.data(), typeof option == 'object' && option) + if (!data) $this.data('oc.sensitive', (data = new Sensitive(this, options))) + if (typeof option == 'string') result = data[option].apply(data, args) + if (typeof result != 'undefined') return false + }) + + return result ? result : this + } + + $.fn.sensitive.noConflict = function () { + $.fn.sensitive = old + return this + } + + $(document).render(function () { + $('[data-control="sensitive"]').sensitive() + }); + +}(window.jQuery); diff --git a/modules/backend/formwidgets/sensitive/assets/less/sensitive.less b/modules/backend/formwidgets/sensitive/assets/less/sensitive.less new file mode 100644 index 0000000000..5717658f29 --- /dev/null +++ b/modules/backend/formwidgets/sensitive/assets/less/sensitive.less @@ -0,0 +1,10 @@ +@import "../../../../assets/less/core/boot.less"; + +div[data-control="sensitive"] { + a[data-toggle], + a[data-copy] { + box-shadow: none; + border: 1px solid @input-group-addon-border-color; + border-left: 0; + } +} diff --git a/modules/backend/formwidgets/sensitive/partials/_sensitive.htm b/modules/backend/formwidgets/sensitive/partials/_sensitive.htm new file mode 100644 index 0000000000..b913070d7f --- /dev/null +++ b/modules/backend/formwidgets/sensitive/partials/_sensitive.htm @@ -0,0 +1,41 @@ +
data-hide-on-tab-change="true" +> +
+
+ previewMode): ?>disabled="disabled" + autocomplete="off" + data-input + /> + + + + + + + + +
+
+ +
+
+
diff --git a/modules/system/models/mailsetting/fields.yaml b/modules/system/models/mailsetting/fields.yaml index c2457ec380..d49851cbae 100644 --- a/modules/system/models/mailsetting/fields.yaml +++ b/modules/system/models/mailsetting/fields.yaml @@ -79,6 +79,7 @@ tabs: smtp_password: label: system::lang.mail.smtp_password tab: system::lang.mail.general + type: sensitive span: right trigger: action: show @@ -107,6 +108,7 @@ tabs: label: system::lang.mail.mailgun_secret commentAbove: system::lang.mail.mailgun_secret_comment tab: system::lang.mail.general + type: sensitive trigger: action: show field: send_mode @@ -116,6 +118,7 @@ tabs: label: system::lang.mail.mandrill_secret commentAbove: system::lang.mail.mandrill_secret_comment tab: system::lang.mail.general + type: sensitive trigger: action: show field: send_mode @@ -135,6 +138,7 @@ tabs: label: system::lang.mail.ses_secret commentAbove: system::lang.mail.ses_secret_comment tab: system::lang.mail.general + type: sensitive span: right trigger: action: show @@ -154,6 +158,7 @@ tabs: sparkpost_secret: label: system::lang.mail.sparkpost_secret commentAbove: system::lang.mail.sparkpost_secret_comment + type: sensitive tab: system::lang.mail.general trigger: action: show