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>
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);
......
......@@ -10843,12 +10843,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 ""
......@@ -14187,6 +14193,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