Scaffold UI elements for minimal version

Add a masked switch to variable rows
Copy some behavior from the protected switch
This commit is contained in:
Miranda Fluharty 2019-03-27 19:13:55 +00:00 committed by Kamil Trzciński
parent 95325c6ab7
commit fc8f4b62f8
17 changed files with 207 additions and 62 deletions

View file

@ -40,6 +40,12 @@ export default class VariableList {
// converted. we need the value as a string. // converted. we need the value as a string.
default: $('.js-ci-variable-input-protected').attr('data-default'), default: $('.js-ci-variable-input-protected').attr('data-default'),
}, },
masked: {
selector: '.js-ci-variable-input-masked',
// use `attr` instead of `data` as we don't want the value to be
// converted. we need the value as a string.
default: $('.js-ci-variable-input-masked').attr('data-default'),
},
environment_scope: { environment_scope: {
// We can't use a `.js-` class here because // We can't use a `.js-` class here because
// gl_dropdown replaces the <input> and doesn't copy over the class // gl_dropdown replaces the <input> and doesn't copy over the class
@ -88,13 +94,16 @@ export default class VariableList {
} }
}); });
// Always make sure there is an empty last row this.$container.on('input trigger-change', inputSelector, e => {
this.$container.on('input trigger-change', inputSelector, () => { // Always make sure there is an empty last row
const $lastRow = this.$container.find('.js-row').last(); const $lastRow = this.$container.find('.js-row').last();
if (this.checkIfRowTouched($lastRow)) { if (this.checkIfRowTouched($lastRow)) {
this.insertRow($lastRow); this.insertRow($lastRow);
} }
// If masked, validate value against regex
this.validateMaskability($(e.currentTarget).closest('.js-row'));
}); });
} }
@ -171,12 +180,33 @@ export default class VariableList {
checkIfRowTouched($row) { checkIfRowTouched($row) {
return Object.keys(this.inputMap).some(name => { return Object.keys(this.inputMap).some(name => {
// Row should not qualify as touched if only switches have been touched
if (['protected', 'masked'].includes(name)) return false;
const entry = this.inputMap[name]; const entry = this.inputMap[name];
const $el = $row.find(entry.selector); const $el = $row.find(entry.selector);
return $el.length && $el.val() !== entry.default; return $el.length && $el.val() !== entry.default;
}); });
} }
validateMaskability($row) {
const invalidInputClass = 'gl-field-error-outline';
const maskableRegex = /^\w{8,}$/; // Eight or more alphanumeric characters plus underscores
const variableValue = $row.find(this.inputMap.secret_value.selector).val();
const isValueMaskable = maskableRegex.test(variableValue) || variableValue === '';
const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true';
// Show a validation error if the user wants to mask an unmaskable variable value
$row
.find(this.inputMap.secret_value.selector)
.toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
$row
.find('.js-secret-value-placeholder')
.toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
$row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable);
}
toggleEnableRow(isEnabled = true) { toggleEnableRow(isEnabled = true) {
this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);

View file

@ -66,6 +66,7 @@
} }
} }
.ci-variable-masked-item,
.ci-variable-protected-item { .ci-variable-protected-item {
flex: 0 1 auto; flex: 0 1 auto;
display: flex; display: flex;

View file

@ -41,7 +41,7 @@ module Groups
end end
def variable_params_attributes def variable_params_attributes
%i[id key secret_value protected _destroy] %i[id key secret_value protected masked _destroy]
end end
def authorize_admin_build! def authorize_admin_build!

View file

@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController
end end
def variable_params_attributes def variable_params_attributes
%i[id key secret_value protected _destroy] %i[id key secret_value protected masked _destroy]
end end
end end

View file

@ -12,4 +12,12 @@ module CiVariablesHelper
ci_variable_protected_by_default? ci_variable_protected_by_default?
end end
end end
def ci_variable_masked?(variable, only_key_value)
if variable && !only_key_value
variable.masked
else
true
end
end
end end

View file

@ -6,4 +6,5 @@ class GroupVariableEntity < Grape::Entity
expose :value expose :value
expose :protected?, as: :protected expose :protected?, as: :protected
expose :masked?, as: :masked
end end

View file

@ -6,4 +6,5 @@ class VariableEntity < Grape::Entity
expose :value expose :value
expose :protected?, as: :protected expose :protected?, as: :protected
expose :masked?, as: :masked
end end

View file

@ -1,3 +1,3 @@
= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want.') = _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they will be masked by default so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.')
= _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe = _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe
= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables') = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables')

View file

@ -1,7 +1,7 @@
- expanded = local_assigns.fetch(:expanded) - expanded = local_assigns.fetch(:expanded)
%h4 %h4
= _('Environment variables') = _('Variables')
= link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer' = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.btn-default.js-settings-toggle{ type: 'button' } %button.btn.btn-default.js-settings-toggle{ type: 'button' }

View file

@ -7,12 +7,15 @@
- value = variable&.value - value = variable&.value
- is_protected_default = ci_variable_protected_by_default? - is_protected_default = ci_variable_protected_by_default?
- is_protected = ci_variable_protected?(variable, only_key_value) - is_protected = ci_variable_protected?(variable, only_key_value)
- is_masked_default = true
- is_masked = ci_variable_masked?(variable, only_key_value)
- id_input_name = "#{form_field}[variables_attributes][][id]" - id_input_name = "#{form_field}[variables_attributes][][id]"
- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" - destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
- key_input_name = "#{form_field}[variables_attributes][][key]" - key_input_name = "#{form_field}[variables_attributes][][key]"
- value_input_name = "#{form_field}[variables_attributes][][secret_value]" - value_input_name = "#{form_field}[variables_attributes][][secret_value]"
- protected_input_name = "#{form_field}[variables_attributes][][protected]" - protected_input_name = "#{form_field}[variables_attributes][][protected]"
- masked_input_name = "#{form_field}[variables_attributes][][masked]"
%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } } %li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
.ci-variable-row-body .ci-variable-row-body
@ -22,7 +25,7 @@
name: key_input_name, name: key_input_name,
value: key, value: key,
placeholder: s_('CiVariables|Input variable key') } placeholder: s_('CiVariables|Input variable key') }
.ci-variable-body-item .ci-variable-body-item.gl-show-field-errors
.form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) } .form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) }
= '*' * 20 = '*' * 20
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id), %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
@ -30,6 +33,7 @@
name: value_input_name, name: value_input_name,
placeholder: s_('CiVariables|Input variable value') } placeholder: s_('CiVariables|Input variable value') }
= value = value
%p.masking-validation-error.gl-field-error.hide= s_("CiVariables|This variable will not be masked")
- unless only_key_value - unless only_key_value
.ci-variable-body-item.ci-variable-protected-item .ci-variable-body-item.ci-variable-protected-item
.append-right-default .append-right-default
@ -46,5 +50,21 @@
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
- unless only_key_value
.ci-variable-body-item.ci-variable-masked-item
.append-right-default
= s_("CiVariable|Masked")
%button{ type: 'button',
class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_masked}",
"aria-label": s_("CiVariable|Toggle masked") }
%input{ type: "hidden",
class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
name: masked_input_name,
value: is_masked,
data: { default: is_masked_default.to_s } }
%span.toggle-icon
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
%button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= icon('minus-circle') = icon('minus-circle')

View file

@ -0,0 +1,5 @@
---
title: Add control for masking variable values in runner logs
merge_request: 25476
author:
type: added

View file

@ -1620,6 +1620,9 @@ msgstr ""
msgid "CiVariables|Remove variable row" msgid "CiVariables|Remove variable row"
msgstr "" msgstr ""
msgid "CiVariables|This variable will not be masked"
msgstr ""
msgid "CiVariable|* (All environments)" msgid "CiVariable|* (All environments)"
msgstr "" msgstr ""
@ -1629,9 +1632,15 @@ msgstr ""
msgid "CiVariable|Error occurred while saving variables" msgid "CiVariable|Error occurred while saving variables"
msgstr "" msgstr ""
msgid "CiVariable|Masked"
msgstr ""
msgid "CiVariable|Protected" msgid "CiVariable|Protected"
msgstr "" msgstr ""
msgid "CiVariable|Toggle masked"
msgstr ""
msgid "CiVariable|Toggle protected" msgid "CiVariable|Toggle protected"
msgstr "" msgstr ""
@ -3181,10 +3190,7 @@ msgstr ""
msgid "Enter the merge request title" msgid "Enter the merge request title"
msgstr "" msgstr ""
msgid "Environment variables" msgid "Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they will be masked by default so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want."
msgstr ""
msgid "Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use environment variables for passwords, secret keys, or whatever you want."
msgstr "" msgstr ""
msgid "Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default" msgid "Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default"
@ -8948,6 +8954,9 @@ msgstr ""
msgid "Value" msgid "Value"
msgstr "" msgstr ""
msgid "Variables"
msgstr ""
msgid "Various container registry settings." msgid "Various container registry settings."
msgstr "" msgstr ""

View file

@ -3,7 +3,7 @@ require 'spec_helper'
describe 'Group variables', :js do describe 'Group variables', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group) } let(:group) { create(:group) }
let!(:variable) { create(:ci_group_variable, key: 'test_key', value: 'test_value', group: group) } let!(:variable) { create(:ci_group_variable, key: 'test_key', value: 'test_value', masked: true, group: group) }
let(:page_path) { group_settings_ci_cd_path(group) } let(:page_path) { group_settings_ci_cd_path(group) }
before do before do

View file

@ -3,7 +3,7 @@ require 'spec_helper'
describe 'Project variables', :js do describe 'Project variables', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
let(:variable) { create(:ci_variable, key: 'test_key', value: 'test_value') } let(:variable) { create(:ci_variable, key: 'test_key', value: 'test_value', masked: true) }
let(:page_path) { project_settings_ci_cd_path(project) } let(:page_path) { project_settings_ci_cd_path(project) }
before do before do

View file

@ -4,12 +4,14 @@
"id", "id",
"key", "key",
"value", "value",
"masked",
"protected" "protected"
], ],
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"key": { "type": "string" }, "key": { "type": "string" },
"value": { "type": "string" }, "value": { "type": "string" },
"masked": { "type": "boolean" },
"protected": { "type": "boolean" }, "protected": { "type": "boolean" },
"environment_scope": { "type": "string", "optional": true } "environment_scope": { "type": "string", "optional": true }
}, },

View file

@ -127,20 +127,25 @@ describe('VariableList', () => {
variableList.init(); variableList.init();
}); });
it('should add another row when editing the last rows protected checkbox', done => { it('should not add another row when editing the last rows protected checkbox', done => {
const $row = $wrapper.find('.js-row:last-child'); const $row = $wrapper.find('.js-row:last-child');
$row.find('.ci-variable-protected-item .js-project-feature-toggle').click(); $row.find('.ci-variable-protected-item .js-project-feature-toggle').click();
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => { .then(() => {
expect($wrapper.find('.js-row').length).toBe(2); expect($wrapper.find('.js-row').length).toBe(1);
})
.then(done)
.catch(done.fail);
});
// Check for the correct default in the new row it('should not add another row when editing the last rows masked checkbox', done => {
const $protectedInput = $wrapper const $row = $wrapper.find('.js-row:last-child');
.find('.js-row:last-child') $row.find('.ci-variable-masked-item .js-project-feature-toggle').click();
.find('.js-ci-variable-input-protected');
expect($protectedInput.val()).toBe('false'); getSetTimeoutPromise()
.then(() => {
expect($wrapper.find('.js-row').length).toBe(1);
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);

View file

@ -23,25 +23,7 @@ shared_examples 'variable list' do
end end
end end
it 'adds empty variable' do it 'adds a new protected variable' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-key').value).to eq('key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('')
end
end
it 'adds new protected variable' do
page.within('.js-ci-variable-list-section .js-row:last-child') do page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key') find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value') find('.js-ci-variable-input-value').set('key_value')
@ -63,6 +45,27 @@ shared_examples 'variable list' do
end end
end end
it 'defaults to masked' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('key')
find('.js-ci-variable-input-value').set('key_value')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
# We check the first row because it re-sorts to alphabetical order on refresh
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-key').value).to eq('key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
end
context 'defaults to the application setting' do context 'defaults to the application setting' do
context 'application setting is true' do context 'application setting is true' do
before do before do
@ -163,27 +166,6 @@ shared_examples 'variable list' do
end end
end end
it 'edits variable with empty value' do
page.within('.js-ci-variable-list-section') do
click_button('Reveal value')
page.within('.js-row:nth-child(1)') do
find('.js-ci-variable-input-key').set('new_key')
find('.js-ci-variable-input-value').set('')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-key').value).to eq('new_key')
expect(find('.js-ci-variable-input-value', visible: false).value).to eq('')
end
end
end
it 'edits variable to be protected' do it 'edits variable to be protected' do
# Create the unprotected variable # Create the unprotected variable
page.within('.js-ci-variable-list-section .js-row:last-child') do page.within('.js-ci-variable-list-section .js-row:last-child') do
@ -251,6 +233,57 @@ shared_examples 'variable list' do
end end
end end
it 'edits variable to be unmasked' do
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
end
end
it 'edits variable to be masked' do
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
find('.ci-variable-masked-item .js-project-feature-toggle').click
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
click_button('Save variables')
wait_for_requests
visit page_path
page.within('.js-ci-variable-list-section .js-row:nth-child(1)') do
expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
end
end
it 'handles multiple edits and deletion in the middle' do it 'handles multiple edits and deletion in the middle' do
page.within('.js-ci-variable-list-section') do page.within('.js-ci-variable-list-section') do
# Create 2 variables # Create 2 variables
@ -297,11 +330,11 @@ shared_examples 'variable list' do
it 'shows validation error box about duplicate keys' do it 'shows validation error box about duplicate keys' do
page.within('.js-ci-variable-list-section .js-row:last-child') do page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('samekey') find('.js-ci-variable-input-key').set('samekey')
find('.js-ci-variable-input-value').set('value1') find('.js-ci-variable-input-value').set('value123')
end end
page.within('.js-ci-variable-list-section .js-row:last-child') do page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('samekey') find('.js-ci-variable-input-key').set('samekey')
find('.js-ci-variable-input-value').set('value2') find('.js-ci-variable-input-value').set('value456')
end end
click_button('Save variables') click_button('Save variables')
@ -314,4 +347,34 @@ shared_examples 'variable list' do
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/) expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/)
end end
end end
it 'shows validation error box about empty values' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('empty_value')
find('.js-ci-variable-input-value').set('')
end
click_button('Save variables')
wait_for_requests
page.within('.js-ci-variable-list-section') do
expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
end
end
it 'shows validation error box about unmaskable values' do
page.within('.js-ci-variable-list-section .js-row:last-child') do
find('.js-ci-variable-input-key').set('unmaskable_value')
find('.js-ci-variable-input-value').set('???')
end
click_button('Save variables')
wait_for_requests
page.within('.js-ci-variable-list-section') do
expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
end
end
end end