Commit d0aca5a0 authored by Scott Hampton's avatar Scott Hampton

Add button for rotating instance ID

Maintainers now have the ability to rotate
the instance ID for feature flag configuration.
parent c64f7473
<script>
import { GlModal, GlButton } from '@gitlab/ui';
import { GlModal, GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
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 {
modalTitle: s__('FeatureFlags|Configure feature flags'),
......@@ -9,11 +11,23 @@ export default {
apiUrlCopyText: __('Copy URL to clipboard'),
instanceIdLabelText: s__('FeatureFlags|Instance ID'),
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: {
GlModal,
GlButton,
ModalCopyButton,
Icon,
Callout,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
......@@ -29,17 +43,27 @@ export default {
type: String,
required: true,
},
instanceId: {
type: String,
required: true,
},
modalId: {
type: String,
required: false,
default: 'configure-feature-flags',
},
isRotating: {
type: Boolean,
required: true,
},
hasRotateError: {
type: Boolean,
required: true,
},
canUserRotateToken: {
type: Boolean,
required: true,
},
},
computed: {
......@@ -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}',
),
{
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_start: `<a href="${this.helpPath}">`,
docs_link_start: `<a href="${this.helpPath}" target="_blank">`,
docs_link_end: '</a>',
},
false,
);
},
},
methods: {
rotateToken() {
this.$emit('token');
},
},
};
</script>
<template>
......@@ -82,7 +112,7 @@ export default {
:text="apiUrl"
:title="$options.apiUrlCopyText"
:modal-id="modalId"
class="input-group-text btn btn-default"
class="input-group-text"
/>
</span>
</div>
......@@ -97,16 +127,45 @@ export default {
type="text"
name="instance_id"
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
:text="instanceId"
:title="$options.instanceIdCopyText"
:modal-id="modalId"
class="input-group-text btn btn-default"
:disabled="isRotating"
class="input-group-text"
/>
</span>
</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>
</template>
......@@ -52,6 +52,11 @@ export default {
type: String,
required: true,
},
rotateInstanceIdPath: {
type: String,
required: false,
default: '',
},
unleashApiUrl: {
type: String,
required: true,
......@@ -82,7 +87,20 @@ export default {
disabled: 'disabled',
},
computed: {
...mapState(['featureFlags', 'count', 'pageInfo', 'isLoading', 'hasError', 'options']),
...mapState([
'featureFlags',
'count',
'pageInfo',
'isLoading',
'hasError',
'options',
'instanceId',
'isRotating',
'hasRotateError',
]),
canUserRotateToken() {
return this.rotateInstanceIdPath !== '';
},
shouldRenderTabs() {
/* Do not show tabs until after the first request to get the count */
return this.count.all !== undefined;
......@@ -144,9 +162,18 @@ export default {
this.setFeatureFlagsEndpoint(this.endpoint);
this.setFeatureFlagsOptions({ scope: this.scope, page: this.page });
this.fetchFeatureFlags();
this.setInstanceId(this.unleashApiInstanceId);
this.setInstanceIdEndpoint(this.rotateInstanceIdPath);
},
methods: {
...mapActions(['setFeatureFlagsEndpoint', 'setFeatureFlagsOptions', 'fetchFeatureFlags']),
...mapActions([
'setFeatureFlagsEndpoint',
'setFeatureFlagsOptions',
'fetchFeatureFlags',
'setInstanceIdEndpoint',
'setInstanceId',
'rotateInstanceId',
]),
onChangeTab(scope) {
this.scope = scope;
this.updateFeatureFlagOptions({
......@@ -183,8 +210,12 @@ export default {
:help-path="featureFlagsHelpPagePath"
:help-anchor="featureFlagsAnchoredHelpPagePath"
: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"
@token="rotateInstanceId()"
/>
<h3 class="page-title with-button">
{{ s__('FeatureFlags|Feature Flags') }}
......
......@@ -25,6 +25,7 @@ export default () =>
csrfToken: csrf.token,
canUserConfigure: this.dataset.canUserAdminFeatureFlag,
newFeatureFlagPath: this.dataset.newFeatureFlagPath,
rotateInstanceIdPath: this.dataset.rotateInstanceIdPath,
},
});
},
......
......@@ -37,7 +37,7 @@ export const rotateInstanceId = ({ state, dispatch }) => {
dispatch('requestRotateInstanceId');
axios
.get(state.rotateEndpoint)
.post(state.rotateEndpoint)
.then(({ data = {}, headers }) => dispatch('receiveRotateInstanceIdSuccess', { data, headers }))
.catch(() => dispatch('receiveRotateInstanceIdError'));
};
......
......@@ -11,6 +11,9 @@ export default {
[types.SET_INSTANCE_ID_ENDPOINT](state, endpoint) {
state.rotateEndpoint = endpoint;
},
[types.SET_INSTANCE_ID](state, instance) {
state.instanceId = instance;
},
[types.REQUEST_FEATURE_FLAGS](state) {
state.isLoading = true;
},
......
......@@ -25,3 +25,7 @@ $label-blue: #428bca;
.clear-search-input {
top: 1px;
}
.instance-id-loading-icon {
right: 84px;
}
......@@ -7,4 +7,5 @@
"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)),
"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', () => {
unleashApiUrl: `${TEST_HOST}/api/unleash`,
unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
canUserConfigure: true,
canUserRotateToken: true,
newFeatureFlagPath: 'feature-flags/new',
};
......@@ -41,6 +42,7 @@ describe('Feature Flags', () => {
errorStateSvgPath: '/assets/illustrations/feature_flag.svg',
featureFlagsHelpPagePath: '/help/feature-flags',
canUserConfigure: false,
canUserRotateToken: false,
featureFlagsAnchoredHelpPagePath: '/help/feature-flags#unleash-clients',
unleashApiUrl: `${TEST_HOST}/api/unleash`,
unleashApiInstanceId: 'oP6sCNRqtRHmpy1gw2-F',
......@@ -255,4 +257,22 @@ describe('Feature Flags', () => {
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', () => {
describe('success', () => {
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(
rotateInstanceId,
......
......@@ -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', () => {
it('should set isLoading to true', () => {
mutations[types.REQUEST_FEATURE_FLAGS](stateCopy);
......
......@@ -10840,12 +10840,18 @@ msgid_plural "Refreshing in %d seconds to show the updated status..."
msgstr[0] ""
msgstr[1] ""
msgid "Regenerate instance ID"
msgstr ""
msgid "Regenerate key"
msgstr ""
msgid "Regenerate recovery codes"
msgstr ""
msgid "Regenerating the instance ID can break integration depending on the client you are using."
msgstr ""
msgid "Regex pattern"
msgstr ""
......@@ -14184,6 +14190,9 @@ msgstr ""
msgid "Unable to connect to server: %{error}"
msgstr ""
msgid "Unable to generate new instance ID"
msgstr ""
msgid "Unable to load the diff. %{button_try_again}"
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