Commit 0aeae598 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'add-rotating-tokens' into 'master'

Add Button for Maintainers to Rotate Instance Id

See merge request gitlab-org/gitlab-ee!13722
parents aeb314e3 d0aca5a0
<script> <script>
import { GlModal, GlButton } from '@gitlab/ui'; import { GlModal, GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale'; import { s__, __, sprintf } from '~/locale';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import Callout from '~/vue_shared/components/callout.vue';
export default { export default {
modalTitle: s__('FeatureFlags|Configure feature flags'), modalTitle: s__('FeatureFlags|Configure feature flags'),
...@@ -9,11 +11,23 @@ export default { ...@@ -9,11 +11,23 @@ export default {
apiUrlCopyText: __('Copy URL to clipboard'), apiUrlCopyText: __('Copy URL to clipboard'),
instanceIdLabelText: s__('FeatureFlags|Instance ID'), instanceIdLabelText: s__('FeatureFlags|Instance ID'),
instanceIdCopyText: __('Copy ID to clipboard'), instanceIdCopyText: __('Copy ID to clipboard'),
regenerateInstanceIdTooltip: __('Regenerate instance ID'),
instanceIdRegenerateError: __('Unable to generate new instance ID'),
instanceIdRegenerateText: __(
'Regenerating the instance ID can break integration depending on the client you are using.',
),
components: { components: {
GlModal, GlModal,
GlButton, GlButton,
ModalCopyButton, ModalCopyButton,
Icon,
Callout,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
...@@ -29,17 +43,27 @@ export default { ...@@ -29,17 +43,27 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
instanceId: { instanceId: {
type: String, type: String,
required: true, required: true,
}, },
modalId: { modalId: {
type: String, type: String,
required: false, required: false,
default: 'configure-feature-flags', default: 'configure-feature-flags',
}, },
isRotating: {
type: Boolean,
required: true,
},
hasRotateError: {
type: Boolean,
required: true,
},
canUserRotateToken: {
type: Boolean,
required: true,
},
}, },
computed: { computed: {
...@@ -49,15 +73,21 @@ export default { ...@@ -49,15 +73,21 @@ export default {
'FeatureFlags|Install a %{docs_link_anchored_start}compatible client library%{docs_link_anchored_end} and specify the API URL, application name, and instance ID during the configuration setup. %{docs_link_start}More Information%{docs_link_end}', 'FeatureFlags|Install a %{docs_link_anchored_start}compatible client library%{docs_link_anchored_end} and specify the API URL, application name, and instance ID during the configuration setup. %{docs_link_start}More Information%{docs_link_end}',
), ),
{ {
docs_link_anchored_start: `<a href="${this.helpAnchor}">`, docs_link_anchored_start: `<a href="${this.helpAnchor}" target="_blank">`,
docs_link_anchored_end: '</a>', docs_link_anchored_end: '</a>',
docs_link_start: `<a href="${this.helpPath}">`, docs_link_start: `<a href="${this.helpPath}" target="_blank">`,
docs_link_end: '</a>', docs_link_end: '</a>',
}, },
false, false,
); );
}, },
}, },
methods: {
rotateToken() {
this.$emit('token');
},
},
}; };
</script> </script>
<template> <template>
...@@ -82,7 +112,7 @@ export default { ...@@ -82,7 +112,7 @@ export default {
:text="apiUrl" :text="apiUrl"
:title="$options.apiUrlCopyText" :title="$options.apiUrlCopyText"
:modal-id="modalId" :modal-id="modalId"
class="input-group-text btn btn-default" class="input-group-text"
/> />
</span> </span>
</div> </div>
...@@ -97,16 +127,45 @@ export default { ...@@ -97,16 +127,45 @@ export default {
type="text" type="text"
name="instance_id" name="instance_id"
readonly readonly
:disabled="isRotating"
/> />
<span class="input-group-append">
<gl-loading-icon
v-if="isRotating"
class="position-absolute align-self-center instance-id-loading-icon"
/>
<div class="input-group-append">
<gl-button
v-if="canUserRotateToken"
v-gl-tooltip.hover
:title="$options.regenerateInstanceIdTooltip"
class="input-group-text js-ff-rotate-token-button"
@click="rotateToken"
>
<icon name="retry" />
</gl-button>
<modal-copy-button <modal-copy-button
:text="instanceId" :text="instanceId"
:title="$options.instanceIdCopyText" :title="$options.instanceIdCopyText"
:modal-id="modalId" :modal-id="modalId"
class="input-group-text btn btn-default" :disabled="isRotating"
class="input-group-text"
/> />
</span> </div>
</div> </div>
</div> </div>
<div
v-if="hasRotateError"
class="text-danger d-flex align-items-center font-weight-normal mb-2"
>
<icon name="warning" class="mr-1" />
<span>{{ $options.instanceIdRegenerateError }}</span>
</div>
<callout
v-if="canUserRotateToken"
category="info"
:message="$options.instanceIdRegenerateText"
/>
</gl-modal> </gl-modal>
</template> </template>
...@@ -52,6 +52,11 @@ export default { ...@@ -52,6 +52,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
rotateInstanceIdPath: {
type: String,
required: false,
default: '',
},
unleashApiUrl: { unleashApiUrl: {
type: String, type: String,
required: true, required: true,
...@@ -82,7 +87,20 @@ export default { ...@@ -82,7 +87,20 @@ export default {
disabled: 'disabled', disabled: 'disabled',
}, },
computed: { computed: {
...mapState(['featureFlags', 'count', 'pageInfo', 'isLoading', 'hasError', 'options']), ...mapState([
'featureFlags',
'count',
'pageInfo',
'isLoading',
'hasError',
'options',
'instanceId',
'isRotating',
'hasRotateError',
]),
canUserRotateToken() {
return this.rotateInstanceIdPath !== '';
},
shouldRenderTabs() { shouldRenderTabs() {
/* Do not show tabs until after the first request to get the count */ /* Do not show tabs until after the first request to get the count */
return this.count.all !== undefined; return this.count.all !== undefined;
...@@ -144,9 +162,18 @@ export default { ...@@ -144,9 +162,18 @@ export default {
this.setFeatureFlagsEndpoint(this.endpoint); this.setFeatureFlagsEndpoint(this.endpoint);
this.setFeatureFlagsOptions({ scope: this.scope, page: this.page }); this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
this.fetchFeatureFlags(); this.fetchFeatureFlags();
this.setInstanceId(this.unleashApiInstanceId);
this.setInstanceIdEndpoint(this.rotateInstanceIdPath);
}, },
methods: { methods: {
...mapActions(['setFeatureFlagsEndpoint', 'setFeatureFlagsOptions', 'fetchFeatureFlags']), ...mapActions([
'setFeatureFlagsEndpoint',
'setFeatureFlagsOptions',
'fetchFeatureFlags',
'setInstanceIdEndpoint',
'setInstanceId',
'rotateInstanceId',
]),
onChangeTab(scope) { onChangeTab(scope) {
this.scope = scope; this.scope = scope;
this.updateFeatureFlagOptions({ this.updateFeatureFlagOptions({
...@@ -183,8 +210,12 @@ export default { ...@@ -183,8 +210,12 @@ export default {
:help-path="featureFlagsHelpPagePath" :help-path="featureFlagsHelpPagePath"
:help-anchor="featureFlagsAnchoredHelpPagePath" :help-anchor="featureFlagsAnchoredHelpPagePath"
:api-url="unleashApiUrl" :api-url="unleashApiUrl"
:instance-id="unleashApiInstanceId" :instance-id="instanceId"
:is-rotating="isRotating"
:has-rotate-error="hasRotateError"
:can-user-rotate-token="canUserRotateToken"
modal-id="configure-feature-flags" modal-id="configure-feature-flags"
@token="rotateInstanceId()"
/> />
<h3 class="page-title with-button"> <h3 class="page-title with-button">
{{ s__('FeatureFlags|Feature Flags') }} {{ s__('FeatureFlags|Feature Flags') }}
......
...@@ -25,6 +25,7 @@ export default () => ...@@ -25,6 +25,7 @@ export default () =>
csrfToken: csrf.token, csrfToken: csrf.token,
canUserConfigure: this.dataset.canUserAdminFeatureFlag, canUserConfigure: this.dataset.canUserAdminFeatureFlag,
newFeatureFlagPath: this.dataset.newFeatureFlagPath, newFeatureFlagPath: this.dataset.newFeatureFlagPath,
rotateInstanceIdPath: this.dataset.rotateInstanceIdPath,
}, },
}); });
}, },
......
...@@ -37,7 +37,7 @@ export const rotateInstanceId = ({ state, dispatch }) => { ...@@ -37,7 +37,7 @@ export const rotateInstanceId = ({ state, dispatch }) => {
dispatch('requestRotateInstanceId'); dispatch('requestRotateInstanceId');
axios axios
.get(state.rotateEndpoint) .post(state.rotateEndpoint)
.then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers })) .then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers }))
.catch(() => dispatch('receiveRotateInstanceIdError')); .catch(() => dispatch('receiveRotateInstanceIdError'));
}; };
......
...@@ -11,6 +11,9 @@ export default { ...@@ -11,6 +11,9 @@ export default {
[types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) { [types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) {
state.rotateEndpoint = endpoint; state.rotateEndpoint = endpoint;
}, },
[types.SET_INSTANCE_ID](state, instance) {
state.instanceId = instance;
},
[types.REQUEST_FEATURE_FLAGS](state) { [types.REQUEST_FEATURE_FLAGS](state) {
state.isLoading = true; state.isLoading = true;
}, },
......
...@@ -25,3 +25,7 @@ $label-blue: #428bca; ...@@ -25,3 +25,7 @@ $label-blue: #428bca;
.clear-search-input { .clear-search-input {
top: 1px; top: 1px;
} }
.instance-id-loading-icon {
right: 84px;
}
...@@ -7,4 +7,5 @@ ...@@ -7,4 +7,5 @@
"unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)), "unleash-api-url" => (unleash_api_url(@project) if can?(current_user, :admin_feature_flag, @project)),
"unleash-api-instance-id" => (unleash_api_instance_id(@project) if can?(current_user, :admin_feature_flag, @project)), "unleash-api-instance-id" => (unleash_api_instance_id(@project) if can?(current_user, :admin_feature_flag, @project)),
"can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project), "can-user-admin-feature-flag" => can?(current_user, :admin_feature_flag, @project),
"new-feature-flag-path" => can?(current_user, :create_feature_flag, @project) ? new_project_feature_flag_path(@project): nil } } "new-feature-flag-path" => can?(current_user, :create_feature_flag, @project) ? new_project_feature_flag_path(@project): nil,
"rotate-instance-id-path" => can?(current_user, :admin_feature_flags_client, @project) ? reset_token_project_feature_flags_client_path(@project, format: :json) : nil } }
---
title: Add Ability for Maintainers to Rotate Instance Id in Feature Flags
merge_request: 13722
author:
type: added
import { shallowMount, createLocalVue } from '@vue/test-utils';
import component from 'ee/feature_flags/components/configure_feature_flags_modal.vue';
const localVue = createLocalVue();
describe('Configure Feature Flags Modal', () => {
const Component = localVue.extend(component);
let wrapper;
let propsData;
afterEach(() => wrapper.destroy());
beforeEach(() => {
propsData = {
helpPath: '/help/path',
helpAnchor: '/help/path/#flags',
apiUrl: '/api/url',
instanceId: 'instance-id-token',
isRotating: false,
hasRotateError: false,
canUserRotateToken: true,
};
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
describe('rotate token', () => {
it('should emit a `token` event on click', () => {
wrapper.find('.js-ff-rotate-token-button').trigger('click');
expect(wrapper.emitted('token')).not.toBeEmpty();
});
it('should display an error if there is a rotate error', () => {
wrapper.setProps({ hasRotateError: true });
expect(wrapper.find('.text-danger')).toExist();
expect(wrapper.find('[name="warning"]')).toExist();
});
it('should be hidden if the user cannot rotate tokens', () => {
wrapper.setProps({ canUserRotateToken: false });
expect(wrapper.find('.js-ff-rotate-token-button').exists()).toBe(false);
});
});
describe('instance id', () => {
it('should be displayed in an input box', () => {
const input = wrapper.find('#instance_id');
expect(input.element.value).toBe('instance-id-token');
});
});
describe('api url', () => {
it('should be displayed in an input box', () => {
const input = wrapper.find('#api_url');
expect(input.element.value).toBe('/api/url');
});
});
describe('help text', () => {
it('should be displayed', () => {
const help = wrapper.find('p');
expect(help.text()).toMatch(/More Information/);
});
it('should have links to the documentation', () => {
const help = wrapper.find('p');
const link = help.find('a[href="/help/path"]');
expect(link.exists()).toBe(true);
const anchoredLink = help.find('a[href="/help/path/#flags"]');
expect(anchoredLink.exists()).toBe(true);
});
});
});
...@@ -16,6 +16,7 @@ describe('Feature Flags', () => { ...@@ -16,6 +16,7 @@ describe('Feature Flags', () => {
unleashApiUrl: `${TEST_HOST}/api/unleash`, unleashApiUrl: `${TEST_HOST}/api/unleash`,
unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
canUserConfigure: true, canUserConfigure: true,
canUserRotateToken: true,
newFeatureFlagPath: 'feature-flags/new', newFeatureFlagPath: 'feature-flags/new',
}; };
...@@ -41,6 +42,7 @@ describe('Feature Flags', () => { ...@@ -41,6 +42,7 @@ describe('Feature Flags', () => {
errorStateSvgPath: '/assets/illustrations/feature_flag.svg', errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
featureFlagsHelpPagePath: '/help/feature-flags', featureFlagsHelpPagePath: '/help/feature-flags',
canUserConfigure: false, canUserConfigure: false,
canUserRotateToken: false,
featureFlagsAnchoredHelpPagePath: '/help/feature-flags#unleash-clients', featureFlagsAnchoredHelpPagePath: '/help/feature-flags#unleash-clients',
unleashApiUrl: `${TEST_HOST}/api/unleash`, unleashApiUrl: `${TEST_HOST}/api/unleash`,
unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F', unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
...@@ -255,4 +257,22 @@ describe('Feature Flags', () => { ...@@ -255,4 +257,22 @@ describe('Feature Flags', () => {
expect(component.$el.querySelector('.js-ff-new')).not.toBeNull(); expect(component.$el.querySelector('.js-ff-new')).not.toBeNull();
}); });
}); });
describe('rotate instance id', () => {
beforeEach(done => {
component = mountComponent(FeatureFlagsComponent, mockData);
setTimeout(() => {
done();
}, 0);
});
it('should fire the rotate action when a `token` event is received', () => {
const actionSpy = spyOn(component, 'rotateInstanceId');
const [modal] = component.$children;
modal.$emit('token');
expect(actionSpy).toHaveBeenCalled();
});
});
}); });
...@@ -195,7 +195,7 @@ describe('Feature flags actions', () => { ...@@ -195,7 +195,7 @@ describe('Feature flags actions', () => {
describe('success', () => { describe('success', () => {
it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => { it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', done => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {});
testAction( testAction(
rotateInstanceId, rotateInstanceId,
......
...@@ -27,6 +27,22 @@ describe('Feature flags store Mutations', () => { ...@@ -27,6 +27,22 @@ describe('Feature flags store Mutations', () => {
}); });
}); });
describe('SET_INSTANCE_ID_ENDPOINT', () => {
it('should set provided endpoint', () => {
mutations[types.SET_INSTANCE_ID_ENDPOINT](stateCopy, 'rotate_token.json');
expect(stateCopy.rotateEndpoint).toEqual('rotate_token.json');
});
});
describe('SET_INSTANCE_ID', () => {
it('should set provided token', () => {
mutations[types.SET_INSTANCE_ID](stateCopy, rotateData.token);
expect(stateCopy.instanceId).toEqual(rotateData.token);
});
});
describe('REQUEST_FEATURE_FLAGS', () => { describe('REQUEST_FEATURE_FLAGS', () => {
it('should set isLoading to true', () => { it('should set isLoading to true', () => {
mutations[types.REQUEST_FEATURE_FLAGS](stateCopy); mutations[types.REQUEST_FEATURE_FLAGS](stateCopy);
......
...@@ -10843,12 +10843,18 @@ msgid_plural "Refreshing in %d seconds to show the updated status..." ...@@ -10843,12 +10843,18 @@ msgid_plural "Refreshing in %d seconds to show the updated status..."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "Regenerate instance ID"
msgstr ""
msgid "Regenerate key" msgid "Regenerate key"
msgstr "" msgstr ""
msgid "Regenerate recovery codes" msgid "Regenerate recovery codes"
msgstr "" msgstr ""
msgid "Regenerating the instance ID can break integration depending on the client you are using."
msgstr ""
msgid "Regex pattern" msgid "Regex pattern"
msgstr "" msgstr ""
...@@ -14187,6 +14193,9 @@ msgstr "" ...@@ -14187,6 +14193,9 @@ msgstr ""
msgid "Unable to connect to server: %{error}" msgid "Unable to connect to server: %{error}"
msgstr "" msgstr ""
msgid "Unable to generate new instance ID"
msgstr ""
msgid "Unable to load the diff. %{button_try_again}" msgid "Unable to load the diff. %{button_try_again}"
msgstr "" msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment