Commit 1e87a36f authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '10746-follow-up-from-scim-specific-token-for-group-sso-convert-to-vue' into 'master'

Convert SCIM token management to Vue

See merge request gitlab-org/gitlab!74743
parents 8f712d10 d7e43926
...@@ -120,5 +120,8 @@ export default { ...@@ -120,5 +120,8 @@ export default {
/> />
</template> </template>
</gl-form-input-group> </gl-form-input-group>
<template v-for="slot in Object.keys($slots)" #[slot]>
<slot :name="slot"></slot>
</template>
</gl-form-group> </gl-form-group>
</template> </template>
---
name: scim_token_vue
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74743
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/347270
milestone: '14.6'
type: development
group: group::access
default_enabled: false
import Vue from 'vue'; import Vue from 'vue';
import { initScimTokenApp } from 'ee/saml_sso';
import MembersApp from './saml_members/index.vue'; import MembersApp from './saml_members/index.vue';
import createStore from './saml_members/store'; import createStore from './saml_members/store';
import initSAML from './shared/init_saml'; import initSAML from './shared/init_saml';
...@@ -21,3 +24,4 @@ function initMembers(el) { ...@@ -21,3 +24,4 @@ function initMembers(el) {
const el = document.querySelector('.js-saml-members'); const el = document.querySelector('.js-saml-members');
initMembers(el); initMembers(el);
initSAML(); initSAML();
initScimTokenApp();
import SamlSettingsForm from 'ee/saml_providers/saml_settings_form'; import SamlSettingsForm from 'ee/saml_providers/saml_settings_form';
import SCIMTokenToggleArea from 'ee/saml_providers/scim_token_toggle_area';
export default function initSAML() { export default function initSAML() {
const groupPath = document.querySelector('#issuer').value;
// eslint-disable-next-line no-new
new SCIMTokenToggleArea(
'.js-generate-scim-token-container',
'.js-scim-token-container',
groupPath,
);
new SamlSettingsForm('#js-saml-settings-form').init(); new SamlSettingsForm('#js-saml-settings-form').init();
} }
<script>
import { GlSprintf, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
import { s__, __ } from '~/locale';
export default {
components: {
GlSprintf,
InputCopyToggleVisibility,
GlButton,
GlLoadingIcon,
GlModal,
},
i18n: {
copyToken: s__('GroupSaml|Copy SCIM token'),
copyEndpointUrl: s__('GroupSaml|Copy SCIM API endpoint URL'),
tokenLabel: s__('GroupSaml|Your SCIM token'),
endpointUrlLabel: s__('GroupSaml|SCIM API endpoint URL'),
generateTokenButtonText: s__('GroupSAML|Generate a SCIM token'),
tokenHasNotBeenGeneratedMessage: s__(
'GroupSAML|Generate a SCIM token to set up your System for Cross-Domain Identity Management.',
),
tokenNeedsToBeResetDescription: s__(
'GroupSAML|The SCIM token is now hidden. To see the value of the token again, you need to %{linkStart}reset it%{linkEnd}.',
),
tokenHasBeenGeneratedOrResetDescription: s__(
"GroupSAML|Make sure you save this token — you won't be able to access it again.",
),
generateTokenErrorMessage: s__(
'GroupSAML|An error occurred generating your SCIM token. Please try again.',
),
resetTokenErrorMessage: s__(
'GroupSAML|An error occurred resetting your SCIM token. Please try again.',
),
modal: {
title: __('Are you sure?'),
body: s__(
'GroupSAML|Are you sure you want to reset the SCIM token? SCIM provisioning will stop working until the new token is updated.',
),
},
},
modal: {
id: 'reset-scim-token-modal',
actionPrimary: {
text: s__('GroupSAML|Reset SCIM token'),
attributes: {
variant: 'danger',
},
},
actionSecondary: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
tokenInputId: 'scim_token',
endpointUrlInputId: 'scim_token_endpoint_url',
inject: ['initialEndpointUrl', 'generateTokenPath'],
data() {
return {
loading: false,
token: '',
endpointUrl: this.initialEndpointUrl,
modalVisible: false,
};
},
computed: {
tokenHasNotBeenGenerated() {
return !this.endpointUrl && this.token === '';
},
tokenNeedsToBeReset() {
return this.endpointUrl && this.token === '';
},
tokenInputValue() {
return this.tokenNeedsToBeReset ? '*'.repeat(20) : this.token;
},
tokenFormInputGroupProps() {
return { id: this.$options.tokenInputId, class: 'gl-form-input-xl' };
},
tokenEndpointUrlFormInputGroupProps() {
return { id: this.$options.endpointUrlInputId, class: 'gl-form-input-xl' };
},
contentContainerClasses() {
return { 'gl-visibility-hidden': this.loading };
},
},
methods: {
async callApi(errorMessage) {
this.loading = true;
try {
const {
data: { scim_api_url: endpointUrl, scim_token: token },
} = await axios.post(this.generateTokenPath);
this.token = token;
this.endpointUrl = endpointUrl;
} catch (error) {
createFlash({
message: errorMessage,
captureError: true,
error,
});
} finally {
this.loading = false;
}
},
handleGenerateTokenButtonClick() {
this.callApi(this.$options.i18n.generateTokenErrorMessage);
},
handleModalPrimary() {
this.callApi(this.$options.i18n.resetTokenErrorMessage);
},
handleResetButtonClick() {
this.modalVisible = true;
},
},
};
</script>
<template>
<div class="gl-mt-5 gl-relative">
<gl-modal
v-model="modalVisible"
:modal-id="$options.modal.id"
:title="$options.i18n.modal.title"
:action-primary="$options.modal.actionPrimary"
:action-secondary="$options.modal.actionSecondary"
size="sm"
@primary="handleModalPrimary"
>
{{ $options.i18n.modal.body }}
</gl-modal>
<div
v-if="loading"
class="gl-absolute gl-top-5 gl-left-0 gl-right-0 gl-display-flex gl-justify-content-center"
>
<gl-loading-icon size="md" />
</div>
<div
v-if="tokenHasNotBeenGenerated"
:class="contentContainerClasses"
data-testid="content-container"
>
<p>
{{ $options.i18n.tokenHasNotBeenGeneratedMessage }}
</p>
<gl-button @click="handleGenerateTokenButtonClick">{{
$options.i18n.generateTokenButtonText
}}</gl-button>
</div>
<div v-else :class="contentContainerClasses" data-testid="content-container">
<input-copy-toggle-visibility
:label="$options.i18n.tokenLabel"
:label-for="$options.tokenInputId"
:form-input-group-props="tokenFormInputGroupProps"
:value="tokenInputValue"
:copy-button-title="$options.i18n.copyToken"
:show-toggle-visibility-button="!tokenNeedsToBeReset"
:show-copy-button="!tokenNeedsToBeReset"
>
<template #description>
<gl-sprintf
v-if="tokenNeedsToBeReset"
:message="$options.i18n.tokenNeedsToBeResetDescription"
>
<template #link="{ content }">
<gl-button variant="link" @click="handleResetButtonClick">{{ content }}</gl-button>
</template>
</gl-sprintf>
<template v-else>
{{ $options.i18n.tokenHasBeenGeneratedOrResetDescription }}
</template>
</template>
</input-copy-toggle-visibility>
<input-copy-toggle-visibility
:label="$options.i18n.endpointUrlLabel"
:label-for="$options.endpointUrlInputId"
:form-input-group-props="tokenEndpointUrlFormInputGroupProps"
:value="endpointUrl"
:copy-button-title="$options.i18n.copyEndpointUrl"
:show-toggle-visibility-button="false"
/>
</div>
</div>
</template>
import Vue from 'vue';
import SCIMTokenToggleArea from 'ee/saml_providers/scim_token_toggle_area';
import ScimToken from './components/scim_token.vue';
import { AUTO_REDIRECT_TO_PROVIDER_BUTTON_SELECTOR } from './constants'; import { AUTO_REDIRECT_TO_PROVIDER_BUTTON_SELECTOR } from './constants';
export const redirectUserWithSSOIdentity = () => { export const redirectUserWithSSOIdentity = () => {
...@@ -9,3 +14,34 @@ export const redirectUserWithSSOIdentity = () => { ...@@ -9,3 +14,34 @@ export const redirectUserWithSSOIdentity = () => {
signInButton.click(); signInButton.click();
}; };
export const initScimTokenApp = () => {
const el = document.getElementById('js-scim-token-app');
if (!el) {
// `scim_token_vue` feature flag is disabled, load legacy JS.
const groupPath = document.querySelector('#issuer').value;
// eslint-disable-next-line no-new
new SCIMTokenToggleArea(
'.js-generate-scim-token-container',
'.js-scim-token-container',
groupPath,
);
return false;
}
const { endpointUrl, generateTokenPath } = el.dataset;
return new Vue({
el,
provide: {
initialEndpointUrl: endpointUrl,
generateTokenPath,
},
render(createElement) {
return createElement(ScimToken);
},
});
};
- scim_token_not_present = @scim_token_url.nil? - if Feature.enabled?(:scim_token_vue, default_enabled: :yaml)
.gl-mt-3.js-generate-scim-token-container{ class: "#{ 'd-none' unless scim_token_not_present}" } #js-scim-token-app{ data: { endpoint_url: @scim_token_url, generate_token_path: group_scim_oauth_path } }
- else
- scim_token_not_present = @scim_token_url.nil?
.gl-mt-3.js-generate-scim-token-container{ class: "#{ 'd-none' unless scim_token_not_present}" }
%p= s_('GroupSAML|Generate a SCIM token to set up your System for Cross-Domain Identity Management.') %p= s_('GroupSAML|Generate a SCIM token to set up your System for Cross-Domain Identity Management.')
%button.btn.gl-button.js-generate-scim-token{ type: 'button' } %button.btn.gl-button.js-generate-scim-token{ type: 'button' }
= s_('GroupSAML|Generate a SCIM token') = s_('GroupSAML|Generate a SCIM token')
.gl-mt-3.js-scim-token-container{ class: "#{ 'd-none' if scim_token_not_present}" } .gl-mt-3.js-scim-token-container{ class: "#{ 'd-none' if scim_token_not_present}" }
.well-segment.borderless.mb-3.col-12.col-lg-9.p-0 .well-segment.borderless.mb-3.col-12.col-lg-9.p-0
= render 'scim_row', value: '********************', field: 'scim_token', label_text: s_('GroupSAML|Your SCIM token'), show_clipboard: false = render 'scim_row', value: '********************', field: 'scim_token', label_text: s_('GroupSAML|Your SCIM token'), show_clipboard: false
.form-text.text-muted.js-scim-token-helper-text .form-text.text-muted.js-scim-token-helper-text
...@@ -15,5 +18,5 @@ ...@@ -15,5 +18,5 @@
= s_("GroupSAML|Make sure you save this token — you won't be able to access it again.") = s_("GroupSAML|Make sure you save this token — you won't be able to access it again.")
.well-segment.borderless.col-12.col-lg-9.p-0 .well-segment.borderless.col-12.col-lg-9.p-0
= render 'scim_row', value: @scim_token_url, field: 'scim_endpoint_url', label_text: s_('GroupSAML|SCIM API endpoint URL'), show_clipboard: true = render 'scim_row', value: @scim_token_url, field: 'scim_endpoint_url', label_text: s_('GroupSAML|SCIM API endpoint URL'), show_clipboard: true
.gl-mt-3.text-center.js-scim-loading-container.d-none .gl-mt-3.text-center.js-scim-loading-container.d-none
.gl-spinner .gl-spinner
...@@ -3,14 +3,21 @@ ...@@ -3,14 +3,21 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'SCIM Token handling', :js do RSpec.describe 'SCIM Token handling', :js do
let(:user) { create(:user) } include Spec::Support::Helpers::ModalHelpers
let(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
before do before do
group.add_owner(user) group.add_owner(user)
stub_licensed_features(group_saml: true) stub_licensed_features(group_saml: true)
end end
context 'when `scim_token_vue` feature flag is disabled' do
before do
stub_feature_flags(scim_token_vue: false)
end
describe 'group has no existing scim token' do describe 'group has no existing scim token' do
before do before do
sign_in(user) sign_in(user)
...@@ -47,4 +54,64 @@ RSpec.describe 'SCIM Token handling', :js do ...@@ -47,4 +54,64 @@ RSpec.describe 'SCIM Token handling', :js do
end end
end end
end end
end
def find_token_field
page.find_field('Your SCIM token')
end
def find_api_endpoint_url_field
page.find_field('SCIM API endpoint URL')
end
context 'when group has no existing SCIM token' do
before do
sign_in(user)
visit group_saml_providers_path(group)
end
it 'displays generate token form' do
expect(page).to have_content('Generate a SCIM token to set up your System for Cross-Domain Identity Management.')
expect(page).to have_button('Generate a SCIM token')
end
end
context 'when group has existing SCIM token' do
let_it_be(:scim_token) { create(:scim_oauth_access_token, group: group) }
before do
sign_in(user)
visit group_saml_providers_path(group)
end
it 'displays the SCIM form with an obfuscated token' do
expect(page).to have_button('reset it')
expect(find_token_field.value).to eq('********************')
expect(page).not_to have_button('Click to reveal')
expect(page).not_to have_button('Copy SCIM token')
expect(find_api_endpoint_url_field.value).to eq(scim_token.as_entity_json[:scim_api_url])
end
context 'when `reset it` button is clicked' do
before do
accept_gl_confirm(
'Are you sure you want to reset the SCIM token? SCIM provisioning will stop working until the new token is updated.',
button_text: 'Reset SCIM token'
) do
page.click_button('reset it')
end
end
it 'displays the SCIM form with an obfuscated token that can be copied or shown' do
expect(find_api_endpoint_url_field.value).to eq(scim_token.as_entity_json[:scim_api_url])
expect(page).to have_button('Copy SCIM token')
expect(find_token_field.value).to eq('********************')
page.click_button('Click to reveal')
expect(find_token_field.value).not_to eq('********************')
expect(find_token_field.value).not_to eq('')
end
end
end
end end
...@@ -14,6 +14,7 @@ RSpec.describe Groups::SamlProvidersController, '(JavaScript fixtures)', type: : ...@@ -14,6 +14,7 @@ RSpec.describe Groups::SamlProvidersController, '(JavaScript fixtures)', type: :
group.add_owner(user) group.add_owner(user)
allow(Devise).to receive(:omniauth_providers).and_return(%i(group_saml)) allow(Devise).to receive(:omniauth_providers).and_return(%i(group_saml))
stub_licensed_features(group_saml: true) stub_licensed_features(group_saml: true)
stub_feature_flags(scim_token_vue: false)
end end
it 'groups/saml_providers/show.html' do it 'groups/saml_providers/show.html' do
......
import { merge, pickBy, isUndefined } from 'lodash';
import AxiosMockAdapter from 'axios-mock-adapter';
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import ScimToken from 'ee/saml_sso/components/scim_token.vue';
import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
jest.mock('~/flash');
describe('ScimToken', () => {
let wrapper;
let axiosMock;
const defaultProvide = {
initialEndpointUrl: undefined,
generateTokenPath: '/groups/saml-test/-/scim_oauth',
};
const mockApiResponse = {
scim_api_url: 'https://foo.bar/api/scim/v2/groups/saml-test',
scim_token: 'quL51_RR49CcHpjxJN_S',
};
const createComponent = (options = {}) => {
wrapper = mountExtended(
ScimToken,
merge(
{},
{
provide: defaultProvide,
},
options,
),
);
};
const findGenerateTokenButton = () =>
wrapper.findByRole('button', { name: ScimToken.i18n.generateTokenButtonText });
const findResetItButton = () => wrapper.findByRole('button', { name: 'reset it' });
const resetAndConfirm = async () => {
await findResetItButton().trigger('click');
wrapper.findComponent(GlModal).vm.$emit('primary');
};
const expectLoadingIconExists = () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.findByTestId('content-container').classes()).toContain('gl-visibility-hidden');
};
const expectInputRenderedWithProps = (input, props) => {
const {
formInputGroupProps,
value,
copyButtonTitle,
showToggleVisibilityButton,
showCopyButton,
label,
labelFor,
} = props;
expect(input.props()).toMatchObject(
pickBy(
{
formInputGroupProps,
value,
copyButtonTitle,
showToggleVisibilityButton,
showCopyButton,
},
!isUndefined,
),
);
expect(input.attributes()).toMatchObject({
label,
'label-for': labelFor,
});
};
const itShowsLoadingIconThenDisplaysInputs = () => {
it('shows loading icon then displays token in hidden state and SCIM API endpoint URL', async () => {
expectLoadingIconExists();
await waitForPromises();
const [tokenInput, apiEndpointInput] = wrapper.findAllComponents(
InputCopyToggleVisibility,
).wrappers;
expectInputRenderedWithProps(tokenInput, {
formInputGroupProps: { id: ScimToken.tokenInputId, class: 'gl-form-input-xl' },
value: mockApiResponse.scim_token,
copyButtonTitle: ScimToken.i18n.copyToken,
showToggleVisibilityButton: true,
showCopyButton: true,
label: ScimToken.i18n.tokenLabel,
labelFor: ScimToken.tokenInputId,
});
expect(
wrapper.findByText(ScimToken.i18n.tokenHasBeenGeneratedOrResetDescription).exists(),
).toBe(true);
expectInputRenderedWithProps(apiEndpointInput, {
formInputGroupProps: { id: ScimToken.endpointUrlInputId, class: 'gl-form-input-xl' },
value: mockApiResponse.scim_api_url,
copyButtonTitle: ScimToken.i18n.copyEndpointUrl,
showToggleVisibilityButton: false,
label: ScimToken.i18n.endpointUrlLabel,
labelFor: ScimToken.endpointUrlInputId,
});
});
};
const itShowsLoadingIconThenCallsCreateFlash = (expectedErrorMessage) => {
it('shows loading icon then calls `createFlash`', async () => {
expectLoadingIconExists();
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: expectedErrorMessage,
captureError: true,
error: expect.any(Error),
});
});
};
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
axiosMock.restore();
});
describe('when token has not been generated', () => {
beforeEach(() => {
createComponent();
});
it('displays message and button to generate token', () => {
expect(wrapper.findByText(ScimToken.i18n.tokenHasNotBeenGeneratedMessage).exists()).toBe(
true,
);
expect(findGenerateTokenButton().exists()).toBe(true);
});
describe(`when \`${ScimToken.i18n.generateTokenButtonText}\` button is clicked`, () => {
describe('when API request is successful', () => {
beforeEach(async () => {
axiosMock.onPost(defaultProvide.generateTokenPath).reply(200, mockApiResponse);
await findGenerateTokenButton().trigger('click');
});
itShowsLoadingIconThenDisplaysInputs();
});
describe('when API request is not successful', () => {
beforeEach(async () => {
axiosMock.onPost(defaultProvide.generateTokenPath).networkError();
await findGenerateTokenButton().trigger('click');
});
itShowsLoadingIconThenCallsCreateFlash(ScimToken.i18n.generateTokenErrorMessage);
});
});
});
describe('when token has been generated but needs to be reset', () => {
const initialEndpointUrl = 'https://foo.bar/api/scim/v2/groups/saml-test';
beforeEach(() => {
createComponent({
provide: {
initialEndpointUrl,
},
});
});
it('displays message and reset link', () => {
expect(
wrapper
.findByText(
'The SCIM token is now hidden. To see the value of the token again, you need to',
{ exact: false },
)
.exists(),
).toBe(true);
expect(wrapper.findByRole('button', { name: 'reset it' }).exists()).toBe(true);
});
it('displays hidden token and SCIM API endpoint URL', () => {
const [tokenInput, apiEndpointInput] = wrapper.findAllComponents(
InputCopyToggleVisibility,
).wrappers;
expectInputRenderedWithProps(tokenInput, {
formInputGroupProps: { id: ScimToken.tokenInputId, class: 'gl-form-input-xl' },
value: '********************',
showToggleVisibilityButton: false,
showCopyButton: false,
label: ScimToken.i18n.tokenLabel,
labelFor: ScimToken.tokenInputId,
});
expectInputRenderedWithProps(apiEndpointInput, {
formInputGroupProps: { id: ScimToken.endpointUrlInputId, class: 'gl-form-input-xl' },
value: initialEndpointUrl,
copyButtonTitle: ScimToken.i18n.copyEndpointUrl,
showToggleVisibilityButton: false,
label: ScimToken.i18n.endpointUrlLabel,
labelFor: ScimToken.endpointUrlInputId,
});
});
describe('when `reset it` button is clicked', () => {
describe('when API request is successful', () => {
beforeEach(async () => {
axiosMock.onPost(defaultProvide.generateTokenPath).reply(200, mockApiResponse);
resetAndConfirm();
});
itShowsLoadingIconThenDisplaysInputs();
});
describe('when API request is not successful', () => {
beforeEach(async () => {
axiosMock.onPost(defaultProvide.initialEndpointUrl).networkError();
resetAndConfirm();
});
itShowsLoadingIconThenCallsCreateFlash(ScimToken.i18n.resetTokenErrorMessage);
});
});
});
});
...@@ -16731,9 +16731,18 @@ msgstr "" ...@@ -16731,9 +16731,18 @@ msgstr ""
msgid "GroupSAML|Active SAML Group Links (%{count})" msgid "GroupSAML|Active SAML Group Links (%{count})"
msgstr "" msgstr ""
msgid "GroupSAML|An error occurred generating your SCIM token. Please try again."
msgstr ""
msgid "GroupSAML|An error occurred resetting your SCIM token. Please try again."
msgstr ""
msgid "GroupSAML|Are you sure you want to remove the SAML group link?" msgid "GroupSAML|Are you sure you want to remove the SAML group link?"
msgstr "" msgstr ""
msgid "GroupSAML|Are you sure you want to reset the SCIM token? SCIM provisioning will stop working until the new token is updated."
msgstr ""
msgid "GroupSAML|Before enforcing SSO, enable SAML authentication." msgid "GroupSAML|Before enforcing SSO, enable SAML authentication."
msgstr "" msgstr ""
...@@ -16806,6 +16815,9 @@ msgstr "" ...@@ -16806,6 +16815,9 @@ msgstr ""
msgid "GroupSAML|Prohibit outer forks for this group" msgid "GroupSAML|Prohibit outer forks for this group"
msgstr "" msgstr ""
msgid "GroupSAML|Reset SCIM token"
msgstr ""
msgid "GroupSAML|Role to assign members of this SAML group." msgid "GroupSAML|Role to assign members of this SAML group."
msgstr "" msgstr ""
...@@ -16845,6 +16857,9 @@ msgstr "" ...@@ -16845,6 +16857,9 @@ msgstr ""
msgid "GroupSAML|The SCIM token is now hidden. To see the value of the token again, you need to " msgid "GroupSAML|The SCIM token is now hidden. To see the value of the token again, you need to "
msgstr "" msgstr ""
msgid "GroupSAML|The SCIM token is now hidden. To see the value of the token again, you need to %{linkStart}reset it%{linkEnd}."
msgstr ""
msgid "GroupSAML|The case-sensitive group name that will be sent by the SAML identity provider." msgid "GroupSAML|The case-sensitive group name that will be sent by the SAML identity provider."
msgstr "" msgstr ""
...@@ -16878,6 +16893,18 @@ msgstr "" ...@@ -16878,6 +16893,18 @@ msgstr ""
msgid "GroupSAML|recommend persistent ID instead of email" msgid "GroupSAML|recommend persistent ID instead of email"
msgstr "" msgstr ""
msgid "GroupSaml|Copy SCIM API endpoint URL"
msgstr ""
msgid "GroupSaml|Copy SCIM token"
msgstr ""
msgid "GroupSaml|SCIM API endpoint URL"
msgstr ""
msgid "GroupSaml|Your SCIM token"
msgstr ""
msgid "GroupSelect|No matching results" msgid "GroupSelect|No matching results"
msgstr "" msgstr ""
......
...@@ -217,4 +217,15 @@ describe('InputCopyToggleVisibility', () => { ...@@ -217,4 +217,15 @@ describe('InputCopyToggleVisibility', () => {
expect(findCopyButton().props('title')).toBe('Copy token'); expect(findCopyButton().props('title')).toBe('Copy token');
}); });
it('renders slots in `gl-form-group`', () => {
const description = 'Mock input description';
createComponent({
slots: {
description,
},
});
expect(wrapper.findByText(description).exists()).toBe(true);
});
}); });
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