diff options
Diffstat (limited to 'app/assets/javascripts/ci_variable_list')
4 files changed, 278 insertions, 25 deletions
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue new file mode 100644 index 00000000000..f5c2cc57f3f --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_key_field.vue @@ -0,0 +1,169 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + name: 'CiKeyField', + components: { + GlButton, + GlFormGroup, + GlFormInput, + }, + model: { + prop: 'value', + event: 'input', + }, + props: { + tokenList: { + type: Array, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + data() { + return { + results: [], + arrowCounter: -1, + userDismissedResults: false, + suggestionsId: uniqueId('token-suggestions-'), + }; + }, + computed: { + showAutocomplete() { + return this.showSuggestions ? 'off' : 'on'; + }, + showSuggestions() { + return this.results.length > 0; + }, + }, + mounted() { + document.addEventListener('click', this.handleClickOutside); + }, + destroyed() { + document.removeEventListener('click', this.handleClickOutside); + }, + methods: { + closeSuggestions() { + this.results = []; + this.arrowCounter = -1; + }, + handleClickOutside(event) { + if (!this.$el.contains(event.target)) { + this.closeSuggestions(); + } + }, + onArrowDown() { + const newCount = this.arrowCounter + 1; + + if (newCount >= this.results.length) { + this.arrowCounter = 0; + return; + } + + this.arrowCounter = newCount; + }, + onArrowUp() { + const newCount = this.arrowCounter - 1; + + if (newCount < 0) { + this.arrowCounter = this.results.length - 1; + return; + } + + this.arrowCounter = newCount; + }, + onEnter() { + const currentToken = this.results[this.arrowCounter] || this.value; + this.selectToken(currentToken); + }, + onEsc() { + if (!this.showSuggestions) { + this.$emit('input', ''); + } + this.closeSuggestions(); + this.userDismissedResults = true; + }, + onEntry(value) { + this.$emit('input', value); + this.userDismissedResults = false; + + // short circuit so that we don't false match on empty string + if (value.length < 1) { + this.closeSuggestions(); + return; + } + + const filteredTokens = this.tokenList.filter(token => + token.toLowerCase().includes(value.toLowerCase()), + ); + + if (filteredTokens.length) { + this.openSuggestions(filteredTokens); + } else { + this.closeSuggestions(); + } + }, + openSuggestions(filteredResults) { + this.results = filteredResults; + }, + selectToken(value) { + this.$emit('input', value); + this.closeSuggestions(); + this.$emit('key-selected'); + }, + }, +}; +</script> +<template> + <div> + <div class="dropdown position-relative" role="combobox" aria-owns="token-suggestions"> + <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <gl-form-input + id="ci-variable-key" + :value="value" + type="text" + role="searchbox" + class="form-control pl-2 js-env-input" + :autocomplete="showAutocomplete" + aria-autocomplete="list" + aria-controls="token-suggestions" + aria-haspopup="listbox" + :aria-expanded="showSuggestions" + data-qa-selector="ci_variable_key_field" + @input="onEntry" + @keydown.down="onArrowDown" + @keydown.up="onArrowUp" + @keydown.enter.prevent="onEnter" + @keydown.esc.stop="onEsc" + @keydown.tab="closeSuggestions" + /> + </gl-form-group> + + <div + v-show="showSuggestions && !userDismissedResults" + id="ci-variable-dropdown" + class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width" + :class="{ 'd-block': showSuggestions }" + > + <div class="dropdown-content"> + <ul :id="suggestionsId"> + <li + v-for="(result, i) in results" + :key="i" + role="option" + :class="{ 'gl-bg-gray-100': i === arrowCounter }" + :aria-selected="i === arrowCounter" + > + <gl-button tabindex="-1" class="btn-transparent pl-2" @click="selectToken(result)">{{ + result + }}</gl-button> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js new file mode 100644 index 00000000000..9022bf51514 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_autocomplete_tokens.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +import { AWS_ACCESS_KEY_ID, AWS_DEFAULT_REGION, AWS_SECRET_ACCESS_KEY } from '../constants'; + +export const awsTokens = { + [AWS_ACCESS_KEY_ID]: { + name: AWS_ACCESS_KEY_ID, + /* Checks for exactly twenty characters that match key. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9]{20}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, + [AWS_DEFAULT_REGION]: { + name: AWS_DEFAULT_REGION, + }, + [AWS_SECRET_ACCESS_KEY]: { + name: AWS_SECRET_ACCESS_KEY, + /* Checks for exactly forty characters that match secret. + Based on greps suggested by Amazon at: + https://aws.amazon.com/blogs/security/a-safer-way-to-distribute-aws-credentials-to-ec2/ + */ + validation: val => /^[A-Za-z0-9/+=]{40}$/.test(val), + invalidMessage: __('This variable does not match the expected pattern.'), + }, +}; + +export const awsTokenList = Object.keys(awsTokens); diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 316408adfb2..8f5acd4a0a0 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -1,8 +1,4 @@ <script> -import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; -import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { GlDeprecatedButton, GlModal, @@ -14,11 +10,19 @@ import { GlLink, GlIcon, } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; +import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; +import CiKeyField from './ci_key_field.vue'; +import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; export default { modalId: ADD_CI_VARIABLE_MODAL_ID, components: { CiEnvironmentsDropdown, + CiKeyField, GlDeprecatedButton, GlModal, GlFormSelect, @@ -29,6 +33,9 @@ export default { GlLink, GlIcon, }, + mixins: [glFeatureFlagsMixin()], + tokens: awsTokens, + tokenList: awsTokenList, computed: { ...mapState([ 'projectId', @@ -41,23 +48,24 @@ export default { 'selectedEnvironment', ]), canSubmit() { - if (this.variableData.masked && this.maskedState === false) { - return false; - } - return this.variableData.key !== '' && this.variableData.secret_value !== ''; + return ( + this.variableValidationState && + this.variableData.key !== '' && + this.variableData.secret_value !== '' + ); }, canMask() { const regex = RegExp(this.maskableRegex); return regex.test(this.variableData.secret_value); }, displayMaskedError() { - return !this.canMask && this.variableData.masked && this.variableData.secret_value !== ''; + return !this.canMask && this.variableData.masked; }, maskedState() { if (this.displayMaskedError) { return false; } - return null; + return true; }, variableData() { return this.variableBeingEdited || this.variable; @@ -66,7 +74,41 @@ export default { return this.variableBeingEdited ? __('Update variable') : __('Add variable'); }, maskedFeedback() { - return __('This variable can not be masked'); + return this.displayMaskedError ? __('This variable can not be masked.') : ''; + }, + tokenValidationFeedback() { + const tokenSpecificFeedback = this.$options.tokens?.[this.variableData.key]?.invalidMessage; + if (!this.tokenValidationState && tokenSpecificFeedback) { + return tokenSpecificFeedback; + } + return ''; + }, + tokenValidationState() { + // If the feature flag is off, do not validate. Remove when flag is removed. + if (!this.glFeatures.ciKeyAutocomplete) { + return true; + } + + const validator = this.$options.tokens?.[this.variableData.key]?.validation; + + if (validator) { + return validator(this.variableData.secret_value); + } + + return true; + }, + variableValidationFeedback() { + return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; + }, + variableValidationState() { + if ( + this.variableData.secret_value === '' || + (this.tokenValidationState && this.maskedState) + ) { + return true; + } + + return false; }, }, methods: { @@ -82,14 +124,13 @@ export default { 'resetSelectedEnvironment', 'setSelectedEnvironment', ]), - updateOrAddVariable() { - if (this.variableBeingEdited) { - this.updateVariable(this.variableBeingEdited); - } else { - this.addVariable(); - } + deleteVarAndClose() { + this.deleteVariable(this.variableBeingEdited); this.hideModal(); }, + hideModal() { + this.$refs.modal.hide(); + }, resetModalHandler() { if (this.variableBeingEdited) { this.resetEditing(); @@ -98,11 +139,12 @@ export default { } this.resetSelectedEnvironment(); }, - hideModal() { - this.$refs.modal.hide(); - }, - deleteVarAndClose() { - this.deleteVariable(this.variableBeingEdited); + updateOrAddVariable() { + if (this.variableBeingEdited) { + this.updateVariable(this.variableBeingEdited); + } else { + this.addVariable(); + } this.hideModal(); }, }, @@ -119,7 +161,13 @@ export default { @hidden="resetModalHandler" > <form> - <gl-form-group :label="__('Key')" label-for="ci-variable-key"> + <ci-key-field + v-if="glFeatures.ciKeyAutocomplete" + v-model="variableData.key" + :token-list="$options.tokenList" + /> + + <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> <gl-form-input id="ci-variable-key" v-model="variableData.key" @@ -130,12 +178,14 @@ export default { <gl-form-group :label="__('Value')" label-for="ci-variable-value" - :state="maskedState" - :invalid-feedback="maskedFeedback" + :state="variableValidationState" + :invalid-feedback="variableValidationFeedback" > <gl-form-textarea id="ci-variable-value" + ref="valueField" v-model="variableData.secret_value" + :state="variableValidationState" rows="3" max-rows="6" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js index d22138db102..5fe1e32e37e 100644 --- a/app/assets/javascripts/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci_variable_list/constants.js @@ -14,3 +14,8 @@ export const types = { fileType: 'file', allEnvironmentsType: '*', }; + +// AWS TOKEN CONSTANTS +export const AWS_ACCESS_KEY_ID = 'AWS_ACCESS_KEY_ID'; +export const AWS_DEFAULT_REGION = 'AWS_DEFAULT_REGION'; +export const AWS_SECRET_ACCESS_KEY = 'AWS_SECRET_ACCESS_KEY'; |