Commit 4ac2fcc6 authored by Peter Hegman's avatar Peter Hegman

Merge branch...

Merge branch '347209-migrate-registration-token-dropdown-to-input_copy_toggle_visibility-vue' into 'master'

Refactor masked registration token

See merge request gitlab-org/gitlab!84472
parents c84cfe6f 8b34af88
<script> <script>
import { GlButtonGroup, GlButton, GlTooltipDirective } from '@gitlab/ui'; import { s__ } from '~/locale';
import { s__, __ } from '~/locale'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
export default { export default {
components: { components: {
GlButtonGroup, InputCopyToggleVisibility,
GlButton,
ModalCopyButton,
},
directives: {
GlTooltip: GlTooltipDirective,
}, },
props: { props: {
value: { value: {
...@@ -19,65 +13,21 @@ export default { ...@@ -19,65 +13,21 @@ export default {
default: '', default: '',
}, },
}, },
data() {
return {
isMasked: true,
};
},
computed: {
maskLabel() {
if (this.isMasked) {
return __('Click to reveal');
}
return __('Click to hide');
},
maskIcon() {
if (this.isMasked) {
return 'eye';
}
return 'eye-slash';
},
displayedValue() {
if (this.isMasked && this.value?.length) {
return '*'.repeat(this.value.length);
}
return this.value;
},
},
methods: { methods: {
onToggleMasked() { onCopy() {
this.isMasked = !this.isMasked;
},
onCopied() {
// value already in the clipboard, simply notify the user // value already in the clipboard, simply notify the user
this.$toast?.show(s__('Runners|Registration token copied!')); this.$toast?.show(s__('Runners|Registration token copied!'));
}, },
}, },
i18n: { I18N_COPY_BUTTON_TITLE: s__('Runners|Copy registration token'),
copyLabel: s__('Runners|Copy registration token'),
},
}; };
</script> </script>
<template> <template>
<gl-button-group> <input-copy-toggle-visibility
<gl-button class="gl-font-monospace" data-testid="token-value" label> class="gl-m-0"
{{ displayedValue }} :value="value"
</gl-button> data-testid="token-value"
<gl-button :copy-button-title="$options.I18N_COPY_BUTTON_TITLE"
v-gl-tooltip @copy="onCopy"
:aria-label="maskLabel" />
:title="maskLabel"
:icon="maskIcon"
class="gl-w-auto! gl-flex-shrink-0!"
data-testid="toggle-masked"
@click.stop="onToggleMasked"
/>
<modal-copy-button
class="gl-w-auto! gl-flex-shrink-0!"
:aria-label="$options.i18n.copyLabel"
:title="$options.i18n.copyLabel"
:text="value"
@success="onCopied"
/>
</gl-button-group>
</template> </template>
...@@ -110,7 +110,7 @@ export default { ...@@ -110,7 +110,7 @@ export default {
v-gl-tooltip.hover="toggleVisibilityLabel" v-gl-tooltip.hover="toggleVisibilityLabel"
:aria-label="toggleVisibilityLabel" :aria-label="toggleVisibilityLabel"
:icon="toggleVisibilityIcon" :icon="toggleVisibilityIcon"
@click="handleToggleVisibilityButtonClick" @click.stop="handleToggleVisibilityButtonClick"
/> />
<clipboard-button <clipboard-button
v-if="showCopyButton" v-if="showCopyButton"
......
...@@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RegistrationToken from '~/runner/components/registration/registration_token.vue';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue'; import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
...@@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => { ...@@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => {
const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm); const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm);
const findRegistrationToken = () => wrapper.findComponent(RegistrationToken);
const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input');
const findTokenResetDropdownItem = () => const findTokenResetDropdownItem = () =>
wrapper.findComponent(RegistrationTokenResetDropdownItem); wrapper.findComponent(RegistrationTokenResetDropdownItem);
const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked');
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => { const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
mountFn(RegistrationDropdown, { mountFn(RegistrationDropdown, {
...@@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => { ...@@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => {
it('Displays masked value by default', () => { it('Displays masked value by default', () => {
createComponent({}, mount); createComponent({}, mount);
expect(findTokenDropdownItem().text()).toMatchInterpolatedText( expect(findRegistrationTokenInput().element.value).toBe(maskToken);
`Registration token ${maskToken}`,
);
}); });
}); });
...@@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => { ...@@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => {
}); });
it('Updates the token when it gets reset', async () => { it('Updates the token when it gets reset', async () => {
const newToken = 'mock1';
createComponent({}, mount); createComponent({}, mount);
const newToken = 'mock1'; expect(findRegistrationTokenInput().props('value')).not.toBe(newToken);
findTokenResetDropdownItem().vm.$emit('tokenReset', newToken); findTokenResetDropdownItem().vm.$emit('tokenReset', newToken);
findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() });
await nextTick(); await nextTick();
expect(findTokenDropdownItem().text()).toMatchInterpolatedText( expect(findRegistrationToken().props('value')).toBe(newToken);
`Registration token ${newToken}`,
);
}); });
}); });
import { nextTick } from 'vue';
import { GlToast } from '@gitlab/ui'; import { GlToast } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils'; import { createLocalVue } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RegistrationToken from '~/runner/components/registration/registration_token.vue'; import RegistrationToken from '~/runner/components/registration/registration_token.vue';
import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
const mockToken = '01234567890'; const mockToken = '01234567890';
const mockMasked = '***********'; const mockMasked = '***********';
describe('RegistrationToken', () => { describe('RegistrationToken', () => {
let wrapper; let wrapper;
let stopPropagation;
let showToast; let showToast;
const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked'); const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility);
const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
const vueWithGlToast = () => { const vueWithGlToast = () => {
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -22,10 +19,14 @@ describe('RegistrationToken', () => { ...@@ -22,10 +19,14 @@ describe('RegistrationToken', () => {
return localVue; return localVue;
}; };
const createComponent = ({ props = {}, withGlToast = true } = {}) => { const createComponent = ({
props = {},
withGlToast = true,
mountFn = shallowMountExtended,
} = {}) => {
const localVue = withGlToast ? vueWithGlToast() : undefined; const localVue = withGlToast ? vueWithGlToast() : undefined;
wrapper = shallowMountExtended(RegistrationToken, { wrapper = mountFn(RegistrationToken, {
propsData: { propsData: {
value: mockToken, value: mockToken,
...props, ...props,
...@@ -36,61 +37,33 @@ describe('RegistrationToken', () => { ...@@ -36,61 +37,33 @@ describe('RegistrationToken', () => {
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null; showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
}; };
beforeEach(() => {
stopPropagation = jest.fn();
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('Displays masked value by default', () => { it('Displays value and copy button', () => {
expect(wrapper.text()).toBe(mockMasked); createComponent();
});
it('Displays button to reveal token', () => { expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken);
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal'); expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe(
'Copy registration token',
);
}); });
it('Can copy the original token value', () => { // Component integration test to ensure secure masking
expect(findCopyButton().props('text')).toBe(mockToken); it('Displays masked value by default', () => {
createComponent({ mountFn: mountExtended });
expect(wrapper.find('input').element.value).toBe(mockMasked);
}); });
describe('When the reveal icon is clicked', () => { describe('When the copy to clipboard button is clicked', () => {
beforeEach(() => { beforeEach(() => {
findToggleMaskButton().vm.$emit('click', { stopPropagation }); createComponent();
});
it('Click event is not propagated', async () => {
expect(stopPropagation).toHaveBeenCalledTimes(1);
}); });
it('Displays the actual value', () => {
expect(wrapper.text()).toBe(mockToken);
});
it('Can copy the original token value', () => {
expect(findCopyButton().props('text')).toBe(mockToken);
});
it('Displays button to mask token', () => {
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide');
});
it('When user clicks again, displays masked value', async () => {
findToggleMaskButton().vm.$emit('click', { stopPropagation });
await nextTick();
expect(wrapper.text()).toBe(mockMasked);
expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal');
});
});
describe('When the copy to clipboard button is clicked', () => {
it('shows a copied message', () => { it('shows a copied message', () => {
findCopyButton().vm.$emit('success'); findInputCopyToggleVisibility().vm.$emit('copy');
expect(showToast).toHaveBeenCalledTimes(1); expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Registration token copied!'); expect(showToast).toHaveBeenCalledWith('Registration token copied!');
...@@ -98,7 +71,7 @@ describe('RegistrationToken', () => { ...@@ -98,7 +71,7 @@ describe('RegistrationToken', () => {
it('does not fail when toast is not defined', () => { it('does not fail when toast is not defined', () => {
createComponent({ withGlToast: false }); createComponent({ withGlToast: false });
findCopyButton().vm.$emit('success'); findInputCopyToggleVisibility().vm.$emit('copy');
// This block also tests for unhandled errors // This block also tests for unhandled errors
expect(showToast).toBeNull(); expect(showToast).toBeNull();
......
...@@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => { ...@@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => {
}); });
describe('when clicked', () => { describe('when clicked', () => {
let event;
beforeEach(async () => { beforeEach(async () => {
await findRevealButton().trigger('click'); event = { stopPropagation: jest.fn() };
await findRevealButton().trigger('click', event);
}); });
it('displays value', () => { it('displays value', () => {
...@@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => { ...@@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => {
it('emits `visibility-change` event', () => { it('emits `visibility-change` event', () => {
expect(wrapper.emitted('visibility-change')[0]).toEqual([true]); expect(wrapper.emitted('visibility-change')[0]).toEqual([true]);
}); });
it('stops propagation on click event', () => {
// in case the input is located in a dropdown or modal
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
});
}); });
}); });
......
...@@ -35,11 +35,11 @@ RSpec.shared_examples 'shows and resets runner registration token' do ...@@ -35,11 +35,11 @@ RSpec.shared_examples 'shows and resets runner registration token' do
it 'has a registration token' do it 'has a registration token' do
click_on 'Click to reveal' click_on 'Click to reveal'
expect(page.find('[data-testid="token-value"]')).to have_content(registration_token) expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token)
end end
describe 'reset registration token' do describe 'reset registration token' do
let!(:old_registration_token) { find('[data-testid="token-value"]').text } let!(:old_registration_token) { find('[data-testid="token-value"] input').value }
before do before do
click_on 'Reset registration token' click_on 'Reset registration token'
......
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