Commit 475c5acc authored by Tetiana Chupryna's avatar Tetiana Chupryna

Merge branch 'exclude_external_users_from_invite_members_dropdown_for_saml_groups' into 'master'

Filter suggested users by saml provider in invite modal dropdown list [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!63565
parents e625c16d 5e5527ac
...@@ -16,7 +16,12 @@ import Api from '~/api'; ...@@ -16,7 +16,12 @@ import Api from '~/api';
import ExperimentTracking from '~/experimentation/experiment_tracking'; import ExperimentTracking from '~/experimentation/experiment_tracking';
import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
import { INVITE_MEMBERS_IN_COMMENT, GROUP_FILTERS, MEMBER_AREAS_OF_FOCUS } from '../constants'; import {
INVITE_MEMBERS_IN_COMMENT,
GROUP_FILTERS,
USERS_FILTER_ALL,
MEMBER_AREAS_OF_FOCUS,
} from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { import {
responseMessageFromError, responseMessageFromError,
...@@ -72,6 +77,16 @@ export default { ...@@ -72,6 +77,16 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
usersFilter: {
type: String,
required: false,
default: USERS_FILTER_ALL,
},
filterId: {
type: Number,
required: false,
default: null,
},
helpLink: { helpLink: {
type: String, type: String,
required: true, required: true,
...@@ -384,6 +399,8 @@ export default { ...@@ -384,6 +399,8 @@ export default {
class="gl-mb-2" class="gl-mb-2"
:validation-state="validationState" :validation-state="validationState"
:aria-labelledby="$options.membersTokenSelectLabelId" :aria-labelledby="$options.membersTokenSelectLabelId"
:users-filter="usersFilter"
:filter-id="filterId"
@clear="handleMembersTokenSelectClear" @clear="handleMembersTokenSelectClear"
/> />
<group-select <group-select
......
...@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@ ...@@ -3,7 +3,7 @@ import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlIcon, GlSprintf } from '@
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { getUsers } from '~/rest_api'; import { getUsers } from '~/rest_api';
import { SEARCH_DELAY } from '../constants'; import { SEARCH_DELAY, USERS_FILTER_ALL, USERS_FILTER_SAML_PROVIDER_ID } from '../constants';
export default { export default {
components: { components: {
...@@ -26,6 +26,16 @@ export default { ...@@ -26,6 +26,16 @@ export default {
validationState: { validationState: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false,
},
usersFilter: {
type: String,
required: false,
default: USERS_FILTER_ALL,
},
filterId: {
type: Number,
required: false,
default: null, default: null,
}, },
}, },
...@@ -51,6 +61,15 @@ export default { ...@@ -51,6 +61,15 @@ export default {
} }
return ''; return '';
}, },
queryOptions() {
if (this.usersFilter === USERS_FILTER_SAML_PROVIDER_ID) {
return {
saml_provider_id: this.filterId,
...this.$options.defaultQueryOptions,
};
}
return this.$options.defaultQueryOptions;
},
}, },
methods: { methods: {
handleTextInput(query) { handleTextInput(query) {
...@@ -60,7 +79,7 @@ export default { ...@@ -60,7 +79,7 @@ export default {
this.retrieveUsers(query); this.retrieveUsers(query);
}, },
retrieveUsers: debounce(function debouncedRetrieveUsers() { retrieveUsers: debounce(function debouncedRetrieveUsers() {
return getUsers(this.query, this.$options.queryOptions) return getUsers(this.query, this.queryOptions)
.then((response) => { .then((response) => {
this.users = response.data.map((token) => ({ this.users = response.data.map((token) => ({
id: token.id, id: token.id,
...@@ -98,7 +117,7 @@ export default { ...@@ -98,7 +117,7 @@ export default {
this.$emit('clear'); this.$emit('clear');
}, },
}, },
queryOptions: { exclude_internal: true, active: true }, defaultQueryOptions: { exclude_internal: true, active: true },
i18n: { i18n: {
inviteTextMessage: __('Invite "%{email}" by email'), inviteTextMessage: __('Invite "%{email}" by email'),
}, },
......
...@@ -17,3 +17,5 @@ export const GROUP_FILTERS = { ...@@ -17,3 +17,5 @@ export const GROUP_FILTERS = {
export const API_MESSAGES = { export const API_MESSAGES = {
EMAIL_ALREADY_INVITED: __('Invite email has already been taken'), EMAIL_ALREADY_INVITED: __('Invite email has already been taken'),
}; };
export const USERS_FILTER_ALL = 'all';
export const USERS_FILTER_SAML_PROVIDER_ID = 'saml_provider_id';
...@@ -25,6 +25,8 @@ export default function initInviteMembersModal() { ...@@ -25,6 +25,8 @@ export default function initInviteMembersModal() {
groupSelectParentId: parseInt(el.dataset.parentId, 10), groupSelectParentId: parseInt(el.dataset.parentId, 10),
areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions), areasOfFocusOptions: JSON.parse(el.dataset.areasOfFocusOptions),
noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus), noSelectionAreasOfFocus: JSON.parse(el.dataset.noSelectionAreasOfFocus),
usersFilter: el.dataset.usersFilter,
filterId: parseInt(el.dataset.filterId, 10),
}, },
}), }),
}); });
......
...@@ -78,4 +78,9 @@ module InviteMembersHelper ...@@ -78,4 +78,9 @@ module InviteMembersHelper
} }
] ]
end end
# Overridden in EE
def users_filter_data(group)
{}
end
end end
...@@ -2,4 +2,5 @@ ...@@ -2,4 +2,5 @@
.js-invite-members-modal{ data: { is_project: 'false', .js-invite-members-modal{ data: { is_project: 'false',
access_levels: GroupMember.access_level_roles.to_json, access_levels: GroupMember.access_level_roles.to_json,
help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)) } default_access_level: Gitlab::Access::GUEST,
help_link: help_page_url('user/permissions') }.merge(group_select_data(group)).merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) }
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
.js-invite-members-modal{ data: { is_project: 'true', .js-invite-members-modal{ data: { is_project: 'true',
access_levels: ProjectMember.access_level_roles.to_json, access_levels: ProjectMember.access_level_roles.to_json,
help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)) } help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) }
# frozen_string_literal: true
module EE
module InviteMembersHelper
def users_filter_data(group)
root_group = group&.root_ancestor
return {} unless root_group&.enforced_sso? && root_group.saml_provider&.id
{ users_filter: 'saml_provider_id', filter_id: root_group.saml_provider.id }
end
end
end
...@@ -4,21 +4,20 @@ require 'spec_helper' ...@@ -4,21 +4,20 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > List members' do RSpec.describe 'Groups > Members > List members' do
include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::MembersHelpers
let(:user1) { create(:user, name: 'John Doe') } let_it_be(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') } let_it_be(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
context 'with Group SAML identity linked for a user' do context 'with Group SAML identity linked for a user' do
let(:saml_provider) { create(:saml_provider) } let_it_be(:saml_provider) { create(:saml_provider) }
let(:group) { saml_provider.group } let(:group) { saml_provider.group }
before do before do
sign_in(user1) sign_in(user1)
group.add_developer(user1) group.add_developer(user1)
group.add_guest(user2) group.add_guest(user2)
user2.identities.create!(provider: :group_saml, create(:identity, saml_provider: saml_provider, user: user2)
saml_provider: saml_provider,
extern_uid: 'user2@example.com')
end end
it 'shows user with SSO status badge', :js do it 'shows user with SSO status badge', :js do
...@@ -44,45 +43,40 @@ RSpec.describe 'Groups > Members > List members' do ...@@ -44,45 +43,40 @@ RSpec.describe 'Groups > Members > List members' do
end end
context 'with SAML and enforced SSO' do context 'with SAML and enforced SSO' do
let(:saml_provider) { create(:saml_provider, group: group, enabled: true, enforced_sso: true) } let_it_be(:saml_provider) { create(:saml_provider, group: group, enabled: true, enforced_sso: true) }
let(:user3) { create(:user, name: 'Amy with different SAML provider') } let_it_be(:user3) { create(:user, name: 'Amy with different SAML provider') }
let(:user4) { create(:user, name: 'Bob without SAML') } let_it_be(:user4) { create(:user, name: 'Bob without SAML') }
let(:session) { { active_group_sso_sign_ins: { saml_provider.id => DateTime.now } } } let_it_be(:session) { { active_group_sso_sign_ins: { saml_provider.id => DateTime.now } } }
before do before do
stub_licensed_features(group_saml: true) stub_licensed_features(group_saml: true)
allow(Gitlab::Session).to receive(:current).and_return(session) allow(Gitlab::Session).to receive(:current).and_return(session)
create(:identity, saml_provider: saml_provider, user: user1)
group.add_owner(user1)
sign_in(user1) sign_in(user1)
end end
it 'returns only users with SAML in autocomplete', :js do before_all do
create(:identity, saml_provider: saml_provider, user: user2) create(:identity, provider: 'group_saml1', saml_provider_id: saml_provider.id, user: user1)
create(:identity, provider: 'group_saml1', saml_provider_id: saml_provider.id, user: user2)
create(:identity, user: user3) create(:identity, user: user3)
group.add_owner(user1)
end
it 'returns only users with SAML in autocomplete', :js do
visit group_group_members_path(group) visit group_group_members_path(group)
wait_for_requests
click_on 'Invite members' click_on 'Invite members'
page.within '#invite-members-modal' do page.within '#invite-members-modal' do
[user1, user2].each do |user_with_saml| field = find('[data-testid="members-token-select-input"]')
find('[data-testid="members-token-select-input"]').set(user_with_saml.name) field.native.send_keys :tab
wait_for_requests field.click
expect(page).to have_content(user_with_saml.name)
end
[user3, user4].each do |user_without_saml| wait_for_requests
find('[data-testid="members-token-select-input"]').set(user_without_saml.name)
wait_for_requests
expect(page).not_to have_content(user_without_saml.name) expect(page).to have_content(user1.name)
end expect(page).to have_content(user2.name)
expect(page).not_to have_content(user3.name)
expect(page).not_to have_content(user4.name)
end end
end end
...@@ -92,9 +86,6 @@ RSpec.describe 'Groups > Members > List members' do ...@@ -92,9 +86,6 @@ RSpec.describe 'Groups > Members > List members' do
end end
it 'returns only users with SAML in autocomplete', :js do it 'returns only users with SAML in autocomplete', :js do
create(:identity, saml_provider: saml_provider, user: user2)
create(:identity, user: user3)
visit group_group_members_path(group) visit group_group_members_path(group)
wait_for_requests wait_for_requests
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::InviteMembersHelper do
describe '.users_filter_data' do
let_it_be(:group) { create(:group) }
let_it_be(:saml_provider) { create(:saml_provider, group: group) }
let!(:group2) { create(:group) }
context 'when the group has enforced sso' do
before do
allow(group).to receive(:enforced_sso?).and_return(true)
end
context 'when there is a group with a saml provider' do
it 'returns user filter data' do
expected = { users_filter: 'saml_provider_id', filter_id: saml_provider.id }
expect(helper.users_filter_data(group)).to eq expected
end
end
context 'when there is a group without a saml provider' do
it 'does not return user filter data' do
expect(helper.users_filter_data(group2)).to eq({})
end
end
end
context 'when group has enforced sso disabled' do
before do
allow(group).to receive(:enforced_sso?).and_return(false)
end
context 'when there is a group with a saml provider' do
it 'does not return user filter data' do
expect(helper.users_filter_data(group)).to eq({})
end
end
context 'when there is a group without a saml provider' do
it 'does not return user filter data' do
expect(helper.users_filter_data(group2)).to eq({})
end
end
end
end
end
...@@ -12,11 +12,12 @@ const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' }; ...@@ -12,11 +12,12 @@ const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' };
const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' }; const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' };
const allUsers = [user1, user2]; const allUsers = [user1, user2];
const createComponent = () => { const createComponent = (props) => {
return shallowMount(MembersTokenSelect, { return shallowMount(MembersTokenSelect, {
propsData: { propsData: {
ariaLabelledby: label, ariaLabelledby: label,
placeholder, placeholder,
...props,
}, },
stubs: { stubs: {
GlTokenSelector: stubComponent(GlTokenSelector), GlTokenSelector: stubComponent(GlTokenSelector),
...@@ -27,11 +28,6 @@ const createComponent = () => { ...@@ -27,11 +28,6 @@ const createComponent = () => {
describe('MembersTokenSelect', () => { describe('MembersTokenSelect', () => {
let wrapper; let wrapper;
beforeEach(() => {
jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers });
wrapper = createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
...@@ -41,6 +37,8 @@ describe('MembersTokenSelect', () => { ...@@ -41,6 +37,8 @@ describe('MembersTokenSelect', () => {
describe('rendering the token-selector component', () => { describe('rendering the token-selector component', () => {
it('renders with the correct props', () => { it('renders with the correct props', () => {
wrapper = createComponent();
const expectedProps = { const expectedProps = {
ariaLabelledby: label, ariaLabelledby: label,
placeholder, placeholder,
...@@ -51,6 +49,11 @@ describe('MembersTokenSelect', () => { ...@@ -51,6 +49,11 @@ describe('MembersTokenSelect', () => {
}); });
describe('users', () => { describe('users', () => {
beforeEach(() => {
jest.spyOn(UserApi, 'getUsers').mockResolvedValue({ data: allUsers });
wrapper = createComponent();
});
describe('when input is focused for the first time (modal auto-focus)', () => { describe('when input is focused for the first time (modal auto-focus)', () => {
it('does not call the API', async () => { it('does not call the API', async () => {
findTokenSelector().vm.$emit('focus'); findTokenSelector().vm.$emit('focus');
...@@ -90,10 +93,10 @@ describe('MembersTokenSelect', () => { ...@@ -90,10 +93,10 @@ describe('MembersTokenSelect', () => {
await waitForPromises(); await waitForPromises();
expect(UserApi.getUsers).toHaveBeenCalledWith( expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
searchParam, active: true,
wrapper.vm.$options.queryOptions, exclude_internal: true,
); });
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
}); });
...@@ -134,6 +137,8 @@ describe('MembersTokenSelect', () => { ...@@ -134,6 +137,8 @@ describe('MembersTokenSelect', () => {
describe('when text input is blurred', () => { describe('when text input is blurred', () => {
it('clears text input', async () => { it('clears text input', async () => {
wrapper = createComponent();
const tokenSelector = findTokenSelector(); const tokenSelector = findTokenSelector();
tokenSelector.vm.$emit('blur'); tokenSelector.vm.$emit('blur');
...@@ -143,4 +148,33 @@ describe('MembersTokenSelect', () => { ...@@ -143,4 +148,33 @@ describe('MembersTokenSelect', () => {
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
}); });
}); });
describe('when component is mounted for a group using a saml provider', () => {
const searchParam = 'name';
const samlProviderId = 123;
let resolveApiRequest;
beforeEach(() => {
jest.spyOn(UserApi, 'getUsers').mockImplementation(
() =>
new Promise((resolve) => {
resolveApiRequest = resolve;
}),
);
wrapper = createComponent({ filterId: samlProviderId, usersFilter: 'saml_provider_id' });
findTokenSelector().vm.$emit('text-input', searchParam);
});
it('calls the API with the saml provider ID param', () => {
resolveApiRequest({ data: allUsers });
expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
active: true,
exclude_internal: true,
saml_provider_id: samlProviderId,
});
});
});
}); });
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