Commit 65389a3b authored by peterhegman's avatar peterhegman

Fix 2FA management on Safari

Also add GitLab UI form validation and confirmation modal

Changelog: fixed
parent 64a2de22
<script> <script>
import { GlFormInput, GlFormGroup, GlButton, GlForm } from '@gitlab/ui'; import { GlFormInput, GlFormGroup, GlButton, GlForm, GlModal } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale'; import { __ } from '~/locale';
export const i18n = { export const i18n = {
currentPassword: __('Current password'), currentPassword: __('Current password'),
confirmTitle: __('Are you sure?'),
confirmWebAuthn: __( confirmWebAuthn: __(
'Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices.', 'This will invalidate your registered applications and U2F / WebAuthn devices.',
), ),
confirm: __('Are you sure? This will invalidate your registered applications and U2F devices.'), confirm: __('This will invalidate your registered applications and U2F devices.'),
disableTwoFactor: __('Disable two-factor authentication'), disableTwoFactor: __('Disable two-factor authentication'),
disable: __('Disable'),
cancel: __('Cancel'),
regenerateRecoveryCodes: __('Regenerate recovery codes'), regenerateRecoveryCodes: __('Regenerate recovery codes'),
currentPasswordInvalidFeedback: __('Please enter your current password.'),
}; };
export default { export default {
name: 'ManageTwoFactorForm', name: 'ManageTwoFactorForm',
i18n, i18n,
modalId: 'manage-two-factor-auth-confirm-modal',
modalActions: {
primary: {
text: i18n.disable,
attributes: {
variant: 'danger',
},
},
secondary: {
text: i18n.cancel,
attributes: {
variant: 'default',
},
},
},
components: { components: {
GlForm, GlForm,
GlFormInput, GlFormInput,
GlFormGroup, GlFormGroup,
GlButton, GlButton,
GlModal,
}, },
inject: [ inject: [
'webauthnEnabled', 'webauthnEnabled',
...@@ -32,8 +52,11 @@ export default { ...@@ -32,8 +52,11 @@ export default {
], ],
data() { data() {
return { return {
method: '', method: null,
action: '#', action: null,
currentPassword: '',
currentPasswordState: null,
showConfirmModal: false,
}; };
}, },
computed: { computed: {
...@@ -46,9 +69,34 @@ export default { ...@@ -46,9 +69,34 @@ export default {
}, },
}, },
methods: { methods: {
handleFormSubmit(event) { submitForm() {
this.method = event.submitter.dataset.formMethod; this.$refs.form.$el.submit();
this.action = event.submitter.dataset.formAction; },
async handleSubmitButtonClick({ method, action, confirm = false }) {
this.method = method;
this.action = action;
if (this.isCurrentPasswordRequired && this.currentPassword === '') {
this.currentPasswordState = false;
return;
}
this.currentPasswordState = null;
if (confirm) {
this.showConfirmModal = true;
return;
}
// Wait for form action and method to be updated
await this.$nextTick();
this.submitForm();
},
handleModalPrimary() {
this.submitForm();
}, },
}, },
csrf, csrf,
...@@ -57,10 +105,11 @@ export default { ...@@ -57,10 +105,11 @@ export default {
<template> <template>
<gl-form <gl-form
class="gl-display-inline-block" ref="form"
class="gl-sm-display-inline-block"
method="post" method="post"
:action="action" :action="action"
@submit="handleFormSubmit($event)" @submit.prevent
> >
<input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" /> <input type="hidden" name="_method" data-testid="test-2fa-method-field" :value="method" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> <input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
...@@ -69,35 +118,59 @@ export default { ...@@ -69,35 +118,59 @@ export default {
v-if="isCurrentPasswordRequired" v-if="isCurrentPasswordRequired"
:label="$options.i18n.currentPassword" :label="$options.i18n.currentPassword"
label-for="current-password" label-for="current-password"
:state="currentPasswordState"
:invalid-feedback="$options.i18n.currentPasswordInvalidFeedback"
> >
<gl-form-input <gl-form-input
id="current-password" id="current-password"
v-model="currentPassword"
type="password" type="password"
name="current_password" name="current_password"
required :state="currentPasswordState"
data-qa-selector="current_password_field" data-qa-selector="current_password_field"
/> />
</gl-form-group> </gl-form-group>
<gl-button <div class="gl-display-flex gl-flex-wrap">
type="submit" <gl-button
class="btn-danger gl-mr-3 gl-display-inline-block" type="submit"
data-testid="test-2fa-disable-button" class="gl-sm-mr-3 gl-w-full gl-sm-w-auto"
variant="danger" data-testid="test-2fa-disable-button"
:data-confirm="confirmText" variant="danger"
:data-form-action="profileTwoFactorAuthPath" @click.prevent="
:data-form-method="profileTwoFactorAuthMethod" handleSubmitButtonClick({
> method: profileTwoFactorAuthMethod,
{{ $options.i18n.disableTwoFactor }} action: profileTwoFactorAuthPath,
</gl-button> confirm: true,
<gl-button })
type="submit" "
class="gl-display-inline-block" >
data-testid="test-2fa-regenerate-codes-button" {{ $options.i18n.disableTwoFactor }}
:data-form-action="codesProfileTwoFactorAuthPath" </gl-button>
:data-form-method="codesProfileTwoFactorAuthMethod" <gl-button
type="submit"
class="gl-mt-3 gl-sm-mt-0 gl-w-full gl-sm-w-auto"
data-testid="test-2fa-regenerate-codes-button"
@click.prevent="
handleSubmitButtonClick({
method: codesProfileTwoFactorAuthMethod,
action: codesProfileTwoFactorAuthPath,
})
"
>
{{ $options.i18n.regenerateRecoveryCodes }}
</gl-button>
</div>
<gl-modal
v-model="showConfirmModal"
:modal-id="$options.modalId"
size="sm"
:title="$options.i18n.confirmTitle"
:action-primary="$options.modalActions.primary"
:action-secondary="$options.modalActions.secondary"
@primary="handleModalPrimary"
> >
{{ $options.i18n.regenerateRecoveryCodes }} {{ confirmText }}
</gl-button> </gl-modal>
</gl-form> </gl-form>
</template> </template>
...@@ -4585,12 +4585,6 @@ msgstr "" ...@@ -4585,12 +4585,6 @@ msgstr ""
msgid "Are you sure? The device will be signed out of GitLab and all remember me tokens revoked." msgid "Are you sure? The device will be signed out of GitLab and all remember me tokens revoked."
msgstr "" msgstr ""
msgid "Are you sure? This will invalidate your registered applications and U2F / WebAuthn devices."
msgstr ""
msgid "Are you sure? This will invalidate your registered applications and U2F devices."
msgstr ""
msgid "Arrange charts" msgid "Arrange charts"
msgstr "" msgstr ""
...@@ -25684,6 +25678,9 @@ msgstr "" ...@@ -25684,6 +25678,9 @@ msgstr ""
msgid "Please enter or upload a valid license." msgid "Please enter or upload a valid license."
msgstr "" msgstr ""
msgid "Please enter your current password."
msgstr ""
msgid "Please fill in a descriptive name for your group." msgid "Please fill in a descriptive name for your group."
msgstr "" msgstr ""
...@@ -35275,6 +35272,12 @@ msgstr "" ...@@ -35275,6 +35272,12 @@ msgstr ""
msgid "This variable can not be masked." msgid "This variable can not be masked."
msgstr "" msgstr ""
msgid "This will invalidate your registered applications and U2F / WebAuthn devices."
msgstr ""
msgid "This will invalidate your registered applications and U2F devices."
msgstr ""
msgid "This will redirect you to an external sign in page." msgid "This will redirect you to an external sign in page."
msgstr "" msgstr ""
......
...@@ -57,7 +57,9 @@ RSpec.describe 'Two factor auths' do ...@@ -57,7 +57,9 @@ RSpec.describe 'Two factor auths' do
click_button 'Disable two-factor authentication' click_button 'Disable two-factor authentication'
page.accept_alert page.within('[role="dialog"]') do
click_button 'Disable'
end
expect(page).to have_content('You must provide a valid current password') expect(page).to have_content('You must provide a valid current password')
...@@ -65,7 +67,9 @@ RSpec.describe 'Two factor auths' do ...@@ -65,7 +67,9 @@ RSpec.describe 'Two factor auths' do
click_button 'Disable two-factor authentication' click_button 'Disable two-factor authentication'
page.accept_alert page.within('[role="dialog"]') do
click_button 'Disable'
end
expect(page).to have_content('Two-factor authentication has been disabled successfully!') expect(page).to have_content('Two-factor authentication has been disabled successfully!')
expect(page).to have_content('Enable two-factor authentication') expect(page).to have_content('Enable two-factor authentication')
...@@ -95,7 +99,9 @@ RSpec.describe 'Two factor auths' do ...@@ -95,7 +99,9 @@ RSpec.describe 'Two factor auths' do
click_button 'Disable two-factor authentication' click_button 'Disable two-factor authentication'
page.accept_alert page.within('[role="dialog"]') do
click_button 'Disable'
end
expect(page).to have_content('Two-factor authentication has been disabled successfully!') expect(page).to have_content('Two-factor authentication has been disabled successfully!')
expect(page).to have_content('Enable two-factor authentication') expect(page).to have_content('Enable two-factor authentication')
......
import { within } from '@testing-library/dom'; import { GlForm, GlModal } from '@gitlab/ui';
import { GlForm } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import ManageTwoFactorForm, { import ManageTwoFactorForm, {
i18n, i18n,
} from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue'; } from '~/authentication/two_factor_auth/components/manage_two_factor_form.vue';
...@@ -17,100 +16,133 @@ describe('ManageTwoFactorForm', () => { ...@@ -17,100 +16,133 @@ describe('ManageTwoFactorForm', () => {
let wrapper; let wrapper;
const createComponent = (options = {}) => { const createComponent = (options = {}) => {
wrapper = extendedWrapper( wrapper = mountExtended(ManageTwoFactorForm, {
mount(ManageTwoFactorForm, { provide: {
provide: { ...defaultProvide,
...defaultProvide, webauthnEnabled: options?.webauthnEnabled ?? false,
webauthnEnabled: options?.webauthnEnabled ?? false, isCurrentPasswordRequired: options?.currentPasswordRequired ?? true,
isCurrentPasswordRequired: options?.currentPasswordRequired ?? true, },
}, stubs: {
}), GlModal: stubComponent(GlModal, {
); template: `
<div>
<slot name="modal-title"></slot>
<slot></slot>
<slot name="modal-footer"></slot>
</div>`,
}),
},
});
}; };
const queryByText = (text, options) => within(wrapper.element).queryByText(text, options);
const queryByLabelText = (text, options) =>
within(wrapper.element).queryByLabelText(text, options);
const findForm = () => wrapper.findComponent(GlForm); const findForm = () => wrapper.findComponent(GlForm);
const findMethodInput = () => wrapper.findByTestId('test-2fa-method-field'); const findMethodInput = () => wrapper.findByTestId('test-2fa-method-field');
const findDisableButton = () => wrapper.findByTestId('test-2fa-disable-button'); const findDisableButton = () => wrapper.findByTestId('test-2fa-disable-button');
const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button'); const findRegenerateCodesButton = () => wrapper.findByTestId('test-2fa-regenerate-codes-button');
const findConfirmationModal = () => wrapper.findComponent(GlModal);
const itShowsConfirmationModal = (confirmText) => {
it('shows confirmation modal', async () => {
await wrapper.findByLabelText('Current password').setValue('foo bar');
await findDisableButton().trigger('click');
expect(findConfirmationModal().props('visible')).toBe(true);
expect(findConfirmationModal().html()).toContain(confirmText);
});
};
const itShowsValidationMessageIfCurrentPasswordFieldIsEmpty = (findButtonFunction) => {
it('shows validation message if `Current password` is empty', async () => {
await findButtonFunction().trigger('click');
expect(wrapper.findByText(i18n.currentPasswordInvalidFeedback).exists()).toBe(true);
});
};
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
}); });
describe('Current password field', () => { describe('`Current password` field', () => {
it('renders the current password field', () => { describe('when required', () => {
expect(queryByLabelText(i18n.currentPassword).tagName).toEqual('INPUT'); it('renders the current password field', () => {
expect(wrapper.findByLabelText(i18n.currentPassword).exists()).toBe(true);
});
}); });
});
describe('when current password is not required', () => { describe('when not required', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
currentPasswordRequired: false, currentPasswordRequired: false,
});
}); });
});
it('does not render the current password field', () => { it('does not render the current password field', () => {
expect(queryByLabelText(i18n.currentPassword)).toBe(null); expect(wrapper.findByLabelText(i18n.currentPassword).exists()).toBe(false);
});
}); });
}); });
describe('Disable button', () => { describe('Disable button', () => {
it('renders the component with correct attributes', () => { it('renders the component with correct attributes', () => {
expect(findDisableButton().exists()).toBe(true); expect(findDisableButton().exists()).toBe(true);
expect(findDisableButton().attributes()).toMatchObject({
'data-confirm': i18n.confirm,
'data-form-action': defaultProvide.profileTwoFactorAuthPath,
'data-form-method': defaultProvide.profileTwoFactorAuthMethod,
});
}); });
it('has the right confirm text', () => { describe('when clicked', () => {
expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirm); itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findDisableButton);
});
describe('when webauthnEnabled', () => { itShowsConfirmationModal(i18n.confirm);
beforeEach(() => {
createComponent({ describe('when webauthnEnabled', () => {
webauthnEnabled: true, beforeEach(() => {
createComponent({
webauthnEnabled: true,
});
}); });
});
it('has the right confirm text', () => { itShowsConfirmationModal(i18n.confirmWebAuthn);
expect(findDisableButton().attributes('data-confirm')).toBe(i18n.confirmWebAuthn);
}); });
});
it('modifies the form action and method when submitted through the button', async () => { it('modifies the form action and method when submitted through the button', async () => {
const form = findForm(); const form = findForm();
const disableButton = findDisableButton().element; const methodInput = findMethodInput();
const methodInput = findMethodInput(); const submitSpy = jest.spyOn(form.element, 'submit');
await wrapper.findByLabelText('Current password').setValue('foo bar');
await findDisableButton().trigger('click');
expect(form.attributes('action')).toBe(defaultProvide.profileTwoFactorAuthPath);
expect(methodInput.attributes('value')).toBe(defaultProvide.profileTwoFactorAuthMethod);
await form.vm.$emit('submit', { submitter: disableButton }); findConfirmationModal().vm.$emit('primary');
expect(form.attributes('action')).toBe(defaultProvide.profileTwoFactorAuthPath); expect(submitSpy).toHaveBeenCalled();
expect(methodInput.attributes('value')).toBe(defaultProvide.profileTwoFactorAuthMethod); });
}); });
}); });
describe('Regenerate recovery codes button', () => { describe('Regenerate recovery codes button', () => {
it('renders the button', () => { it('renders the button', () => {
expect(queryByText(i18n.regenerateRecoveryCodes)).toEqual(expect.any(HTMLElement)); expect(findRegenerateCodesButton().exists()).toBe(true);
}); });
it('modifies the form action and method when submitted through the button', async () => { describe('when clicked', () => {
const form = findForm(); itShowsValidationMessageIfCurrentPasswordFieldIsEmpty(findRegenerateCodesButton);
const regenerateCodesButton = findRegenerateCodesButton().element;
const methodInput = findMethodInput(); it('modifies the form action and method when submitted through the button', async () => {
const form = findForm();
const methodInput = findMethodInput();
const submitSpy = jest.spyOn(form.element, 'submit');
await form.vm.$emit('submit', { submitter: regenerateCodesButton }); await wrapper.findByLabelText('Current password').setValue('foo bar');
await findRegenerateCodesButton().trigger('click');
expect(form.attributes('action')).toBe(defaultProvide.codesProfileTwoFactorAuthPath); expect(form.attributes('action')).toBe(defaultProvide.codesProfileTwoFactorAuthPath);
expect(methodInput.attributes('value')).toBe(defaultProvide.codesProfileTwoFactorAuthMethod); expect(methodInput.attributes('value')).toBe(
defaultProvide.codesProfileTwoFactorAuthMethod,
);
expect(submitSpy).toHaveBeenCalled();
});
}); });
}); });
}); });
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