Skip to content

Commit

Permalink
Add sensitive field input (#5201)
Browse files Browse the repository at this point in the history
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 <tomasz@init.biz>
Co-authored-by: Luke Towers <github@luketowers.ca>
  • Loading branch information
3 people authored Jul 8, 2020
1 parent b5dcc42 commit 4950edc
Show file tree
Hide file tree
Showing 7 changed files with 369 additions and 0 deletions.
2 changes: 2 additions & 0 deletions modules/backend/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
}

Expand Down
117 changes: 117 additions & 0 deletions modules/backend/formwidgets/Sensitive.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php namespace Backend\FormWidgets;

use Backend\Classes\FormWidgetBase;

/**
* Sensitive widget.
*
* Renders a password field that can be optionally made visible
*
* @package october\backend
*/
class Sensitive extends FormWidgetBase
{
/**
* @var bool If true, the sensitive field cannot be edited, but can be toggled.
*/
public $readOnly = false;

/**
* @var bool If true, the sensitive field is disabled.
*/
public $disabled = false;

/**
* @var bool If true, a button will be available to copy the value.
*/
public $allowCopy = false;

/**
* @var string The string that will be used as a placeholder for an unrevealed sensitive value.
*/
public $hiddenPlaceholder = '__hidden__';

/**
* @var bool If true, the sensitive input will be hidden if the user changes to another tab in their browser.
*/
public $hideOnTabChange = true;

/**
* @inheritDoc
*/
protected $defaultAlias = 'sensitive';

/**
* @inheritDoc
*/
public function init()
{
$this->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');
}
}
Original file line number Diff line number Diff line change
@@ -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}
192 changes: 192 additions & 0 deletions modules/backend/formwidgets/sensitive/assets/js/sensitive.js
Original file line number Diff line number Diff line change
@@ -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);
10 changes: 10 additions & 0 deletions modules/backend/formwidgets/sensitive/assets/less/sensitive.less
Original file line number Diff line number Diff line change
@@ -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;
}
}
41 changes: 41 additions & 0 deletions modules/backend/formwidgets/sensitive/partials/_sensitive.htm
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<div
data-control="sensitive"
data-clean="true"
data-event-handler="<?= $this->getEventHandler('onShowValue') ?>"
<?php if ($hideOnTabChange): ?>data-hide-on-tab-change="true"<?php endif ?>
>
<div class="loading-indicator-container size-form-field">
<div class="input-group">
<input
type="password"
name="<?= $this->getFieldName() ?>"
id="<?= $this->getId() ?>"
value="<?= ($hasValue) ? $hiddenPlaceholder : '' ?>"
placeholder="<?= e(trans($this->formField->placeholder)) ?>"
class="form-control"
<?php if ($this->previewMode): ?>disabled="disabled"<?php endif ?>
autocomplete="off"
data-input
/>
<?php if ($allowCopy): ?>
<a
href="javascript:;"
class="input-group-addon btn btn-secondary"
data-copy
>
<i class="icon-copy"></i>
</a>
<?php endif ?>
<a
href="javascript:;"
class="input-group-addon btn btn-secondary"
data-toggle
>
<i class="icon-eye" data-icon></i>
</a>
</div>
<div class="loading-indicator hide" data-loader>
<span class="p-a"></span>
</div>
</div>
</div>
Loading

0 comments on commit 4950edc

Please sign in to comment.