diff --git a/app/assets/javascripts/admin/users/components/admin_users_filter_app.vue b/app/assets/javascripts/admin/users/components/admin_users_filter_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..b569c6d23fb9727eac1dd4a3e198ccd2d1206679 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/admin_users_filter_app.vue @@ -0,0 +1,65 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { TOKENS } from '../constants'; +import { initializeValuesFromQuery } from '../utils'; + +const TOKEN_TYPES = TOKENS.map(({ type }) => type); + +export default { + name: 'AdminUsersFilterApp', + components: { + GlFilteredSearch, + }, + data() { + const { tokens, sort } = initializeValuesFromQuery(); + + return { + tokens, + sort, + }; + }, + computed: { + availableTokens() { + // Once a token is selected, discard the rest + const token = this.tokens.find(({ type }) => TOKEN_TYPES.includes(type)); + + if (token) { + return TOKENS.filter(({ type }) => type === token.type); + } + + return TOKENS; + }, + }, + methods: { + search(tokens) { + const newParams = {}; + + tokens?.forEach((token) => { + if (typeof token === 'string') { + newParams.search_query = token; + } else { + newParams.filter = token.value.data; + } + }); + + if (this.sort) { + newParams.sort = this.sort; + } + + const newUrl = setUrlParams(newParams, window.location.href, true); + visitUrl(newUrl); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + v-model="tokens" + :placeholder="s__('AdminUsers|Search by name, email, or username')" + :available-tokens="availableTokens" + terms-as-tokens + @submit="search" + /> +</template> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 73383623aa2d310a87f95ff6c83fd1f3bdbe7e7f..8ac77739ad9ee0692fbf67505006261d1c2b5709 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -1,3 +1,6 @@ +import { GlFilteredSearchToken } from '@gitlab/ui'; + +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { s__, __ } from '~/locale'; export const I18N_USER_ACTIONS = { @@ -18,3 +21,46 @@ export const I18N_USER_ACTIONS = { trust: s__('AdminUsers|Trust user'), untrust: s__('AdminUsers|Untrust user'), }; + +export const TOKENS = [ + { + title: s__('AdminUsers|Access level'), + type: 'access_level', + token: GlFilteredSearchToken, + operators: OPERATORS_IS, + unique: true, + options: [ + { value: 'admins', title: s__('AdminUsers|Administrator') }, + { value: 'external', title: s__('AdminUsers|External') }, + ], + }, + { + title: __('State'), + type: 'state', + token: GlFilteredSearchToken, + operators: OPERATORS_IS, + unique: true, + options: [ + { value: 'banned', title: s__('AdminUsers|Banned') }, + { value: 'blocked', title: s__('AdminUsers|Blocked') }, + { value: 'deactivated', title: s__('AdminUsers|Deactivated') }, + { + value: 'blocked_pending_approval', + title: s__('AdminUsers|Pending approval'), + }, + { value: 'trusted', title: s__('AdminUsers|Trusted') }, + { value: 'wop', title: s__('AdminUsers|Without projects') }, + ], + }, + { + title: s__('AdminUsers|Two-factor authentication'), + type: '2fa', + token: GlFilteredSearchToken, + operators: OPERATORS_IS, + unique: true, + options: [ + { value: 'two_factor_enabled', title: __('On') }, + { value: 'two_factor_disabled', title: __('Off') }, + ], + }, +]; diff --git a/app/assets/javascripts/admin/users/index.js b/app/assets/javascripts/admin/users/index.js index 2bd37d3fffe6f4de09bc9ca6fdee0cb56d2f9abd..ae9fe207eff95a549188f743113f72da3dabf394 100644 --- a/app/assets/javascripts/admin/users/index.js +++ b/app/assets/javascripts/admin/users/index.js @@ -4,6 +4,7 @@ import createDefaultClient from '~/lib/graphql'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import csrf from '~/lib/utils/csrf'; import AdminUsersApp from './components/app.vue'; +import AdminUsersFilterApp from './components/admin_users_filter_app.vue'; import DeleteUserModal from './components/modals/delete_user_modal.vue'; import UserActions from './components/user_actions.vue'; @@ -34,12 +35,19 @@ const initApp = (el, component, userPropKey, props = {}) => { }); }; -export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => - initApp(el, AdminUsersApp, 'users'); +export const initAdminUsersFilterApp = () => { + return new Vue({ + el: document.querySelector('#js-admin-users-filter-app'), + render: (createElement) => createElement(AdminUsersFilterApp), + }); +}; export const initAdminUserActions = (el = document.querySelector('#js-admin-user-actions')) => initApp(el, UserActions, 'user', { showButtonLabels: true }); +export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => + initApp(el, AdminUsersApp, 'users'); + export const initDeleteUserModals = () => { return new Vue({ functional: true, diff --git a/app/assets/javascripts/admin/users/utils.js b/app/assets/javascripts/admin/users/utils.js index f6c1091ba27640b725ef1677f2d3ee5f2f95a09b..f40d634321124f0d723c622fc16532e7df65d243 100644 --- a/app/assets/javascripts/admin/users/utils.js +++ b/app/assets/javascripts/admin/users/utils.js @@ -1,3 +1,6 @@ +import { queryToObject } from '~/lib/utils/url_utility'; +import { TOKENS } from './constants'; + export const generateUserPaths = (paths, id) => { return Object.fromEntries( Object.entries(paths).map(([action, genericPath]) => { @@ -5,3 +8,39 @@ export const generateUserPaths = (paths, id) => { }), ); }; + +/** + * @typedef {{type: string, value: {data: string, operator: string}}} Token + */ + +/** + * Initialize token values based on the URL parameters + * @param {string} query - document.location.searchd + * + * @returns {{tokens: Array<string|Token>, sort: string}} + */ +export function initializeValuesFromQuery(query = document.location.search) { + const tokens = []; + + const { filter, search_query: searchQuery, sort } = queryToObject(query); + + if (filter) { + const token = TOKENS.find(({ options }) => options.some(({ value }) => value === filter)); + + if (token) { + tokens.push({ + type: token.type, + value: { + data: filter, + operator: token.operators[0].value, + }, + }); + } + } + + if (searchQuery) { + tokens.push(searchQuery); + } + + return { tokens, sort }; +} diff --git a/app/assets/javascripts/pages/admin/users/index.js b/app/assets/javascripts/pages/admin/users/index.js index 41e99a3baf5415f708ffa157390399eb2276a177..0add5f24e2c382b230ae754269a7ce510bb87ad8 100644 --- a/app/assets/javascripts/pages/admin/users/index.js +++ b/app/assets/javascripts/pages/admin/users/index.js @@ -1,7 +1,13 @@ -import { initAdminUsersApp, initDeleteUserModals, initAdminUserActions } from '~/admin/users'; +import { + initAdminUsersFilterApp, + initAdminUserActions, + initAdminUsersApp, + initDeleteUserModals, +} from '~/admin/users'; import initConfirmModal from '~/confirm_modal'; -initAdminUsersApp(); +initAdminUsersFilterApp(); initAdminUserActions(); +initAdminUsersApp(); initDeleteUserModals(); initConfirmModal(); diff --git a/app/views/admin/cohorts/_cohorts_table.html.haml b/app/views/admin/cohorts/_cohorts_table.html.haml index ef24bd9e2e8132f50272d0b565643aad097232a9..361824e428899e5f99a85dc0b120dccb1ee4fa83 100644 --- a/app/views/admin/cohorts/_cohorts_table.html.haml +++ b/app/views/admin/cohorts/_cohorts_table.html.haml @@ -1,6 +1,6 @@ - number_of_data_columns = @cohorts[:months_included] - 1 -.table-holder.d-xl-table.gl-mt-5 +.table-holder.d-xl-table %table.table %thead %tr diff --git a/app/views/admin/users/_tabs.html.haml b/app/views/admin/users/_tabs.html.haml index 6c14e1189fe91e613d99c153204c80435dd0d0be..6bdfaf0cf12dbae449390c76f2d7d65104776e56 100644 --- a/app/views/admin/users/_tabs.html.haml +++ b/app/views/admin/users/_tabs.html.haml @@ -1,3 +1,3 @@ -= gl_tabs_nav({ class: 'js-users-tabs' }) do += gl_tabs_nav({ class: 'js-users-tabs gl-flex-grow-1 gl-border-0' }) do = gl_tab_link_to s_('AdminUsers|Users'), admin_users_path = gl_tab_link_to s_('AdminUsers|Cohorts'), admin_cohorts_path diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index 3daf3fe19227663e809457f122161cf9290d2fe5..55067bb046fb3bf9cdd08520f45ea3b8cee7f76f 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -7,7 +7,13 @@ - c.with_body do = render 'shared/registration_features_discovery_message', feature_title: s_('RegistrationFeatures|send emails to users') -.top-area +- if Feature.enabled?(:admin_user_filtered_nav) + .gl-display-flex.gl-align-items-flex-start.gl-flex-wrap.gl-md-flex-nowrap.gl-gap-4.row-content-block.gl-border-0{ data: { testid: "filtered-search-block" } } + #js-admin-users-filter-app + .gl-flex-shrink-0 + = label_tag s_('AdminUsers|Sort by') + = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { placement: 'right' } +- else .scrolling-tabs-container.inner-page-scroll-tabs.gl-flex-grow-1.gl-min-w-0.gl-w-full %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } = sprite_icon('chevron-lg-left', size: 12) @@ -17,56 +23,51 @@ = gl_tab_link_to admin_users_path, { item_active: active_when(params[:filter].nil?), class: 'gl-border-0!' } do = s_('AdminUsers|Active') = gl_tab_counter_badge(limited_counter_with_delimiter(User.active_without_ghosts)) - = gl_tab_link_to admin_users_path(filter: "admins"), { item_active: active_when(params[:filter] == 'admins'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "admins"), { item_active: active_when(params[:filter] == 'admins'), class: 'gl-border-0!' } do = s_('AdminUsers|Admins') = gl_tab_counter_badge(limited_counter_with_delimiter(User.admins)) - = gl_tab_link_to admin_users_path(filter: 'two_factor_enabled'), { item_active: active_when(params[:filter] == 'two_factor_enabled'), class: 'filter-two-factor-enabled gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: 'two_factor_enabled'), { item_active: active_when(params[:filter] == 'two_factor_enabled'), class: 'filter-two-factor-enabled gl-border-0!' } do = s_('AdminUsers|2FA Enabled') = gl_tab_counter_badge(limited_counter_with_delimiter(User.with_two_factor)) - = gl_tab_link_to admin_users_path(filter: 'two_factor_disabled'), { item_active: active_when(params[:filter] == 'two_factor_disabled'), class: 'filter-two-factor-disabled gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: 'two_factor_disabled'), { item_active: active_when(params[:filter] == 'two_factor_disabled'), class: 'filter-two-factor-disabled gl-border-0!' } do = s_('AdminUsers|2FA Disabled') = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_two_factor)) - = gl_tab_link_to admin_users_path(filter: 'external'), { item_active: active_when(params[:filter] == 'external'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: 'external'), { item_active: active_when(params[:filter] == 'external'), class: 'gl-border-0!' } do = s_('AdminUsers|External') = gl_tab_counter_badge(limited_counter_with_delimiter(User.external)) - = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "blocked"), { item_active: active_when(params[:filter] == 'blocked'), class: 'gl-border-0!' } do = s_('AdminUsers|Blocked') = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked)) - = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "banned"), { item_active: active_when(params[:filter] == 'banned'), class: 'gl-border-0!' } do = s_('AdminUsers|Banned') = gl_tab_counter_badge(limited_counter_with_delimiter(User.banned)) - = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { testid: 'pending-approval-tab' } } do + = gl_tab_link_to admin_users_path(filter: "blocked_pending_approval"), { item_active: active_when(params[:filter] == 'blocked_pending_approval'), class: 'filter-blocked-pending-approval gl-border-0!', data: { testid: 'pending-approval-tab' } } do = s_('AdminUsers|Pending approval') = gl_tab_counter_badge(limited_counter_with_delimiter(User.blocked_pending_approval)) - = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "deactivated"), { item_active: active_when(params[:filter] == 'deactivated'), class: 'gl-border-0!' } do = s_('AdminUsers|Deactivated') = gl_tab_counter_badge(limited_counter_with_delimiter(User.deactivated)) - = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do = s_('AdminUsers|Without projects') = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects)) - = gl_tab_link_to admin_users_path(filter: "trusted"), { item_active: active_when(params[:filter] == 'trusted'), class: 'gl-border-0!' } do + = gl_tab_link_to admin_users_path(filter: "trusted"), { item_active: active_when(params[:filter] == 'trusted'), class: 'gl-border-0!' } do = s_('AdminUsers|Trusted') = gl_tab_counter_badge(limited_counter_with_delimiter(User.trusted)) - .nav-controls - = render_if_exists 'admin/users/admin_email_users' - = render_if_exists 'admin/users/admin_export_user_permissions' - = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_path) do - = s_('AdminUsers|New user') -.row-content-block.gl-border-0{ data: { testid: "filtered-search-block" } } - = form_tag admin_users_path, method: :get do - - if params[:filter].present? - = hidden_field_tag "filter", h(params[:filter]) - .search-holder - .search-field-holder - = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email, or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { testid: 'user-search-field' } - - if @sort.present? - = hidden_field_tag :sort, @sort - = sprite_icon('search', css_class: 'search-icon') - = button_tag s_('AdminUsers|Search users') if Rails.env.test? - .dropdown.gl-sm-ml-3 - = label_tag s_('AdminUsers|Sort by') - = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { placement: 'right' } + .row-content-block.gl-border-0{ data: { testid: "filtered-search-block" } } + = form_tag admin_users_path, method: :get do + - if params[:filter].present? + = hidden_field_tag "filter", h(params[:filter]) + .search-holder + .search-field-holder + = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email, or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { testid: 'user-search-field' } + - if @sort.present? + = hidden_field_tag :sort, @sort + = sprite_icon('search', css_class: 'search-icon') + = button_tag s_('AdminUsers|Search users') if Rails.env.test? + .dropdown.gl-sm-ml-3 + = label_tag s_('AdminUsers|Sort by') + = gl_redirect_listbox_tag admin_users_sort_options(filter: params[:filter], search_query: params[:search_query]), @sort, data: { placement: 'right' } #js-admin-users-app{ data: admin_users_data_attributes(@users) } = render Pajamas::SpinnerComponent.new(size: :lg, class: 'gl-my-7') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 86b777d8458bbf01f1c4b4af356ce401256e2056..3e14cd8bf490b10482468037719023245edfb2c0 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,6 +1,12 @@ - page_title _("Users") -= render 'tabs' +.top-area + = render 'tabs' + .nav-controls + = render_if_exists 'admin/users/admin_email_users' + = render_if_exists 'admin/users/admin_export_user_permissions' + = render Pajamas::ButtonComponent.new(variant: :confirm, href: new_admin_user_path) do + = s_('AdminUsers|New user') .tab-content .tab-pane.active diff --git a/config/feature_flags/gitlab_com_derisk/admin_user_filtered_nav.yml b/config/feature_flags/gitlab_com_derisk/admin_user_filtered_nav.yml new file mode 100644 index 0000000000000000000000000000000000000000..f639f56c090e635fb99231cae6aa5a6c8e3ddc3c --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/admin_user_filtered_nav.yml @@ -0,0 +1,9 @@ +--- +name: admin_user_filtered_nav +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238183 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/149471 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/455962 +milestone: '17.0' +group: group::authentication +type: gitlab_com_derisk +default_enabled: false diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1817711dda22a872f4a2b50305919637bedfc282..a7e50657f8265cc4da75cc3744011e90d773c6c6 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4366,6 +4366,9 @@ msgstr "" msgid "AdminUsers|Trusted" msgstr "" +msgid "AdminUsers|Two-factor authentication" +msgstr "" + msgid "AdminUsers|Unban user" msgstr "" @@ -49632,6 +49635,9 @@ msgstr "" msgid "Starts: %{startsAt}" msgstr "" +msgid "State" +msgstr "" + msgid "State your message to activate" msgstr "" diff --git a/spec/features/admin/users/users_spec.rb b/spec/features/admin/users/users_spec.rb index d3fe47605174353cb083f2a66d0a7ef604b67ee3..a3c7383097b089295eb7fa0772e35361416125b7 100644 --- a/spec/features/admin/users/users_spec.rb +++ b/spec/features/admin/users/users_spec.rb @@ -8,11 +8,11 @@ include ListboxHelpers let_it_be(:user, reload: true) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') } - let_it_be(:current_user) { create(:admin) } + let_it_be(:admin) { create(:admin) } before do - sign_in(current_user) - enable_admin_mode!(current_user) + sign_in(admin) + enable_admin_mode!(admin) end describe 'GET /admin/users', :js do @@ -25,11 +25,10 @@ end it "has users list" do - current_user.reload + admin.reload - expect(page).to have_content(current_user.email) - expect(page).to have_content(current_user.name) - expect(page).to have_content(current_user.created_at.strftime('%b %d, %Y')) + expect(page).to have_content(admin.name) + expect(page).to have_content(admin.created_at.strftime('%b %d, %Y')) expect(page).to have_content(user.email) expect(page).to have_content(user.name) expect(page).to have_content('Projects') @@ -65,36 +64,35 @@ context 'user project count' do before do - project = create(:project) - project.add_maintainer(current_user) + create(:project, maintainers: admin) end it 'displays count of users projects' do visit admin_users_path - expect(find_by_testid("user-project-count-#{current_user.id}").text).to eq("1") + expect(find_by_testid("user-project-count-#{admin.id}").text).to eq("1") end end - describe 'tabs' do - it 'has multiple tabs to filter users' do - expect(page).to have_link('Active', href: admin_users_path) - expect(page).to have_link('Admins', href: admin_users_path(filter: 'admins')) - expect(page).to have_link('2FA Enabled', href: admin_users_path(filter: 'two_factor_enabled')) - expect(page).to have_link('2FA Disabled', href: admin_users_path(filter: 'two_factor_disabled')) - expect(page).to have_link('External', href: admin_users_path(filter: 'external')) - expect(page).to have_link('Blocked', href: admin_users_path(filter: 'blocked')) - expect(page).to have_link('Deactivated', href: admin_users_path(filter: 'deactivated')) - expect(page).to have_link('Without projects', href: admin_users_path(filter: 'wop')) + context 'when :admin_user_filtered_nav feature flag is disabled' do + before do + stub_feature_flags(admin_user_filtered_nav: false) + visit admin_users_path end - context '`Pending approval` tab' do - before do - visit admin_users_path - end - - it 'shows the `Pending approval` tab' do + describe 'tabs' do + it 'has multiple tabs to filter users' do + expect(page).to have_link('Active', href: admin_users_path) + expect(page).to have_link('Admins', href: admin_users_path(filter: 'admins')) + expect(page).to have_link('2FA Enabled', href: admin_users_path(filter: 'two_factor_enabled')) + expect(page).to have_link('2FA Disabled', href: admin_users_path(filter: 'two_factor_disabled')) + expect(page).to have_link('External', href: admin_users_path(filter: 'external')) + expect(page).to have_link('Blocked', href: admin_users_path(filter: 'blocked')) + expect(page).to have_link('Banned', href: admin_users_path(filter: 'banned')) expect(page).to have_link('Pending approval', href: admin_users_path(filter: 'blocked_pending_approval')) + expect(page).to have_link('Deactivated', href: admin_users_path(filter: 'deactivated')) + expect(page).to have_link('Without projects', href: admin_users_path(filter: 'wop')) + expect(page).to have_link('Trusted', href: admin_users_path(filter: 'trusted')) end end end @@ -133,10 +131,7 @@ end it 'searches with respect of sorting' do - visit admin_users_path(sort: 'name_asc') - - fill_in :search_query, with: 'Foo' - click_button('Search users') + visit admin_users_path(sort: 'name_asc', search_query: 'Foo') expect(first_row.text).to include('Foo Bar') expect(second_row.text).to include('Foo Baz') @@ -162,38 +157,21 @@ end describe 'Two-factor Authentication filters' do - it 'counts users who have enabled 2FA' do - create(:user, :two_factor) - - visit admin_users_path - - page.within('.filter-two-factor-enabled .gl-tab-counter-badge') do - expect(page).to have_content('1') - end - end - it 'filters by users who have enabled 2FA' do - user = create(:user, :two_factor) - - visit admin_users_path - click_link '2FA Enabled' - - expect(page).to have_content(user.email) - end + user_2fa = create(:user, :two_factor) - it 'counts users who have not enabled 2FA' do - visit admin_users_path + visit admin_users_path(filter: 'two_factor_enabled') - page.within('.filter-two-factor-disabled .gl-tab-counter-badge') do - expect(page).to have_content('2') # Including admin - end + expect(page).to have_content(user_2fa.email) + expect(all_users.length).to be(1) end - it 'filters by users who have not enabled 2FA' do - visit admin_users_path - click_link '2FA Disabled' + it 'filters users who have not enabled 2FA' do + visit admin_users_path(filter: 'two_factor_disabled') expect(page).to have_content(user.email) + expect(page).to have_content(admin.email) + expect(all_users.length).to be(2) end end @@ -201,20 +179,18 @@ it 'counts users who are pending approval' do create_list(:user, 2, :blocked_pending_approval) - visit admin_users_path + visit admin_users_path(filter: 'blocked_pending_approval') - page.within('.filter-blocked-pending-approval .gl-tab-counter-badge') do - expect(page).to have_content('2') - end + expect(all_users.length).to be(2) end it 'filters by users who are pending approval' do - user = create(:user, :blocked_pending_approval) + blocked_user = create(:user, :blocked_pending_approval) - visit admin_users_path - click_link 'Pending approval' + visit admin_users_path(filter: 'blocked_pending_approval') - expect(page).to have_content(user.email) + expect(page).to have_content(blocked_user.email) + expect(all_users.length).to be(1) end end @@ -238,7 +214,7 @@ expect(page).to have_content('Successfully blocked') expect(page).not_to have_content(user.email) - click_link 'Blocked' + visit admin_users_path(filter: 'blocked') wait_for_requests @@ -276,7 +252,7 @@ expect(page).to have_content('Successfully deactivated') expect(page).not_to have_content(user.email) - click_link 'Deactivated' + visit admin_users_path(filter: 'deactivated') wait_for_requests @@ -314,11 +290,9 @@ describe 'users pending approval' do it 'sends a welcome email and a password reset email to the user upon admin approval', :sidekiq_inline do - user = create(:user, :blocked_pending_approval, created_by_id: current_user.id) - - visit admin_users_path + user = create(:user, :blocked_pending_approval, created_by_id: admin.id) - click_link 'Pending approval' + visit admin_users_path(filter: 'blocked_pending_approval') click_user_dropdown_toggle(user.id) @@ -368,10 +342,8 @@ context 'user group count', :js do before do - group = create(:group) - group.add_developer(current_user) - project = create(:project, group: create(:group)) - project.add_reporter(current_user) + create(:group, developers: admin) + create(:project, group: create(:group), reporters: admin) end it 'displays count of the users authorized groups' do @@ -379,7 +351,7 @@ wait_for_requests - within_testid("user-group-count-#{current_user.id}") do + within_testid("user-group-count-#{admin.id}") do expect(page).to have_content('2') end end @@ -389,8 +361,7 @@ it 'shows user info', :aggregate_failures do user = create(:user, :blocked_pending_approval) - visit admin_users_path - click_link 'Pending approval' + visit admin_users_path(filter: 'blocked_pending_approval') click_link user.name expect(page).to have_content(user.name) @@ -531,12 +502,10 @@ def expects_warning_to_be_shown end describe 'GET /admin/users/:id/projects' do - let_it_be(:group) { create(:group) } + let_it_be(:group) { create(:group, developers: user) } let_it_be(:project) { create(:project, group: group) } before do - group.add_developer(user) - visit projects_admin_user_path(user) end @@ -657,11 +626,15 @@ def check_breadcrumb(content) end def first_row - page.all('[role="row"]')[1] + all_users[0] end def second_row - page.all('[role="row"]')[2] + all_users[1] + end + + def all_users + page.all('tbody[role="rowgroup"] > tr') end def sort_by(option) diff --git a/spec/frontend/admin/users/components/admin_users_filter_app_spec.js b/spec/frontend/admin/users/components/admin_users_filter_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1c4dd899564ffcfdb9544469929848d8f9072d45 --- /dev/null +++ b/spec/frontend/admin/users/components/admin_users_filter_app_spec.js @@ -0,0 +1,109 @@ +import { GlFilteredSearch } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { visitUrl, getBaseURL } from '~/lib/utils/url_utility'; +import AdminUsersFilterApp from '~/admin/users/components/admin_users_filter_app.vue'; +import { TOKENS } from '~/admin/users/constants'; + +const mockToken = [ + { + type: 'access_level', + value: { data: 'admins', operator: '=' }, + }, +]; + +const accessLevelToken = TOKENS.filter(({ type }) => type === 'access_level'); + +jest.mock('~/lib/utils/url_utility', () => { + return { + ...jest.requireActual('~/lib/utils/url_utility'), + visitUrl: jest.fn(), + }; +}); + +describe('AdminUsersFilterApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(AdminUsersFilterApp); + }; + + const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); + const findAvailableTokens = () => findFilteredSearch().props('availableTokens'); + + it('includes all the tokens', () => { + createComponent(); + const actualOptions = findAvailableTokens() + .flatMap(({ options }) => options.map(({ value }) => value)) + .sort(); + const expectedOptions = TOKENS.flatMap(({ options }) => + options.map(({ value }) => value), + ).sort(); + + expect(actualOptions).toMatchObject(expectedOptions); + }); + + describe('when a token is selected', () => { + /** + * Currently BE support only one filter at the time + * https://gitlab.com/gitlab-org/gitlab/-/issues/254377 + */ + it('discard all other tokens', async () => { + createComponent(); + findFilteredSearch().vm.$emit('input', mockToken); + await nextTick(); + + expect(findAvailableTokens()).toEqual(accessLevelToken); + }); + }); + + describe('when a text token is selected', () => { + it('includes all the tokens', async () => { + createComponent(); + findFilteredSearch().vm.$emit('input', [ + { + type: 'filtered-search-term', + value: { data: 'mytext' }, + }, + ]); + await nextTick(); + + expect(findAvailableTokens()).toEqual(TOKENS); + }); + }); + + describe('initialize tokens based on query search parameters', () => { + /** + * Currently BE support only one filter at the time + * https://gitlab.com/gitlab-org/gitlab/-/issues/254377 + */ + it('includes only one token if `filter` query parameter the TOKENS', () => { + window.history.replaceState({}, '', '/?filter=admins'); + createComponent(); + + expect(findAvailableTokens()).toEqual(accessLevelToken); + }); + + it('replace the initial token when another token is selected', async () => { + window.history.replaceState({}, '', '/?filter=banned'); + createComponent(); + findFilteredSearch().vm.$emit('input', mockToken); + await nextTick(); + + expect(findAvailableTokens()).toEqual(accessLevelToken); + }); + }); + + describe('when user submit a search', () => { + it('keeps `sort` and adds new `search_query` and `filter` query parameter and visit page', async () => { + window.history.replaceState({}, '', '/?filter=banned&sort=oldest_sign_in'); + createComponent(); + findFilteredSearch().vm.$emit('submit', [...mockToken, 'mytext']); + await nextTick(); + + expect(visitUrl).toHaveBeenCalledWith( + `${getBaseURL()}/?filter=admins&search_query=mytext&sort=oldest_sign_in`, + ); + }); + }); +}); diff --git a/spec/frontend/admin/users/utils_spec.js b/spec/frontend/admin/users/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..77bc70476a5964b3d08fdc65caf3e3c9caee205d --- /dev/null +++ b/spec/frontend/admin/users/utils_spec.js @@ -0,0 +1,43 @@ +import { TOKENS } from '~/admin/users/constants'; +import { initializeValuesFromQuery } from '~/admin/users/utils'; + +const allFilters = TOKENS.flatMap(({ type, options, operators }) => + options.map(({ value }) => ({ value, type, operator: operators[0].value })), +); + +describe('initializeValuesFromQuery', () => { + it('parses `search_query` query parameter correctly', () => { + expect(initializeValuesFromQuery('?search_query=dummy')).toMatchObject({ + tokens: ['dummy'], + sort: undefined, + }); + }); + + it.each(allFilters)('parses `filter` query parameter `$value`', ({ value, type, operator }) => { + expect(initializeValuesFromQuery(`?search_query=dummy&filter=${value}`)).toMatchObject({ + tokens: [{ type, value: { data: value, operator } }, 'dummy'], + sort: undefined, + }); + }); + + it('parses `sort` query parameter correctly', () => { + expect(initializeValuesFromQuery('?sort=last_activity_on_asc')).toMatchObject({ + tokens: [], + sort: 'last_activity_on_asc', + }); + }); + + it('ignores `filter` query parameter not found in the TOKEN options', () => { + expect(initializeValuesFromQuery('?filter=unknown')).toMatchObject({ + tokens: [], + sort: undefined, + }); + }); + + it('ignores other query parameters other than `filter` and `search_query` and `sort`', () => { + expect(initializeValuesFromQuery('?other=value')).toMatchObject({ + tokens: [], + sort: undefined, + }); + }); +});