diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js
index 4e63a85df891f247ad9353718fb40c4682bf3737..633bc4d8b15c32793d8a64bad40e6d038d7380d2 100644
--- a/app/assets/javascripts/admin/users/components/actions/index.js
+++ b/app/assets/javascripts/admin/users/components/actions/index.js
@@ -9,6 +9,8 @@ import Reject from './reject.vue';
import Unban from './unban.vue';
import Unblock from './unblock.vue';
import Unlock from './unlock.vue';
+import Trust from './trust_user.vue';
+import Untrust from './untrust_user.vue';
export default {
Activate,
@@ -22,4 +24,6 @@ export default {
Unblock,
Unlock,
Reject,
+ Trust,
+ Untrust,
};
diff --git a/app/assets/javascripts/admin/users/components/actions/trust_user.vue b/app/assets/javascripts/admin/users/components/actions/trust_user.vue
new file mode 100644
index 0000000000000000000000000000000000000000..41ff8d4120de27f8e529151bfc1bb0a848447332
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/actions/trust_user.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/admin/users/components/actions/untrust_user.vue b/app/assets/javascripts/admin/users/components/actions/untrust_user.vue
new file mode 100644
index 0000000000000000000000000000000000000000..da59833af07596d877b613fa26cbf026b9fe3069
--- /dev/null
+++ b/app/assets/javascripts/admin/users/components/actions/untrust_user.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js
index 9cd61d6b1dbcc676fa2ae8201224c0a3c3bc11da..43c9a8749cde76f38d4d2b5f47c8631534322cad 100644
--- a/app/assets/javascripts/admin/users/constants.js
+++ b/app/assets/javascripts/admin/users/constants.js
@@ -19,4 +19,6 @@ export const I18N_USER_ACTIONS = {
deleteWithContributions: s__('AdminUsers|Delete user and contributions'),
ban: s__('AdminUsers|Ban user'),
unban: s__('AdminUsers|Unban user'),
+ trust: s__('AdminUsers|Trust user'),
+ untrust: s__('AdminUsers|Untrust user'),
};
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index 9634257209daf418b0cf504d7cb8bb4744d9e3fc..ee78d5a8c35f46c4e4f08613d525e3510c3bcf6e 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -310,7 +310,7 @@ def paginate_without_count?
end
def users_with_included_associations(users)
- users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
+ users.includes(:authorized_projects, :trusted_with_spam_attribute) # rubocop: disable CodeReuse/ActiveRecord
end
def admin_making_changes_for_another_user?
diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb
index 969c5d5a0b5b1432deba0a9d6437b758c0dc3849..ba40b3c8a8df5a6f639ce153ee85e35797f59cd9 100644
--- a/app/helpers/admin/user_actions_helper.rb
+++ b/app/helpers/admin/user_actions_helper.rb
@@ -16,6 +16,7 @@ def admin_actions(user)
unlock_actions
delete_actions
ban_actions
+ trust_actions
@actions
end
@@ -66,5 +67,19 @@ def ban_actions
@actions << 'ban'
end
end
+
+ def trust_actions
+ return if @user.internal? ||
+ @user.blocked_pending_approval? ||
+ @user.banned? ||
+ @user.blocked? ||
+ @user.deactivated?
+
+ @actions << if @user.trusted?
+ 'untrust'
+ else
+ 'trust'
+ end
+ end
end
end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index a892b6e6ac6f7c39958cffe10398663aad366f49..81c41aee142a70af986d39f924d8631946303ba9 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -262,7 +262,9 @@ def admin_users_paths
delete_with_contributions: admin_user_path(:id, hard_delete: true),
admin_user: admin_user_path(:id),
ban: ban_admin_user_path(:id),
- unban: unban_admin_user_path(:id)
+ unban: unban_admin_user_path(:id),
+ trust: trust_admin_user_path(:id),
+ untrust: untrust_admin_user_path(:id)
}
end
diff --git a/app/models/user.rb b/app/models/user.rb
index fdc0b531521bc1505edf5d788daafa7480f52c40..bc256ef0f310feba437bd062d14f5e84fcba3f93 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -601,6 +601,12 @@ def blocked?
scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) }
scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) }
scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) }
+ scope :trusted, -> do
+ where('EXISTS (?)', ::UserCustomAttribute
+ .select(1)
+ .where('user_id = users.id')
+ .trusted_with_spam)
+ end
strip_attributes! :name
@@ -769,6 +775,8 @@ def filter_items(filter_name)
external
when 'deactivated'
deactivated
+ when "trusted"
+ trusted
else
active_without_ghosts
end
diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml
index d4a9009a0cf1b7d067981b80eca0c382b1ab4391..bbb068c3680e21d379ff29bd15b473baab795048 100644
--- a/app/views/admin/users/_users.html.haml
+++ b/app/views/admin/users/_users.html.haml
@@ -44,6 +44,9 @@
= 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
+ = 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'
diff --git a/doc/administration/moderate_users.md b/doc/administration/moderate_users.md
index b30294c5fe0bd5be4c319ed78f93934cf42cd4cb..c12eb2b9a95045fccbafc6ab0231f787b160065d 100644
--- a/doc/administration/moderate_users.md
+++ b/doc/administration/moderate_users.md
@@ -287,6 +287,45 @@ You can also delete a user and their contributions, such as merge requests, issu
NOTE:
Before 15.1, additionally groups of which deleted user were the only owner among direct members were deleted.
+## Trust and untrust users
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132402) in GitLab 16.5.
+
+You can trust and untrust users from the Admin Area.
+
+By default, a user is not trusted and is blocked from creating issues, notes, and snippets considered to be spam. When you trust a user, they can create issues, notes, and snippets without being blocked.
+
+Prerequisite:
+
+- You must be an administrator.
+
+::Tabs
+
+:::TabTitle Trust a user
+
+1. On the left sidebar, select **Search or go to**.
+1. Select **Admin Area**.
+1. Select **Overview > Users**.
+1. Select a user.
+1. From the **User administration** dropdown list, select **Trust user**.
+1. On the confirmation dialog, select **Trust user**.
+
+The user is trusted.
+
+:::TabTitle Untrust a user
+
+1. On the left sidebar, select **Search or go to**.
+1. Select **Admin Area**.
+1. Select **Overview > Users**.
+1. Select the **Trusted** tab.
+1. Select a user.
+1. From the **User administration** dropdown list, select **Untrust user**.
+1. On the confirmation dialog, select **Untrust user**.
+
+The user is untrusted.
+
+::EndTabs
+
## Troubleshooting
When moderating users, you may need to perform bulk actions on them based on certain conditions. The following rails console scripts show some examples of this. You may [start a rails console session](../administration/operations/rails_console.md#starting-a-rails-console-session) and use scripts similar to the following:
diff --git a/doc/administration/review_spam_logs.md b/doc/administration/review_spam_logs.md
index 35cc78a9bf36d3402d6fc4524b5d287db1194ae5..e3b96cdae9505978c7af6577c8b4db955529c774 100644
--- a/doc/administration/review_spam_logs.md
+++ b/doc/administration/review_spam_logs.md
@@ -38,15 +38,3 @@ You can resolve a spam log with one of the following effects:
NOTE:
Users can be [blocked](../api/users.md#block-user) and
[unblocked](../api/users.md#unblock-user) using the GitLab API.
-
-
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4ba49ace5c768d16ff67af6cd6144fad70b31d63..d0193a2045e42f5515439a7b8d1f8f3369ec3df1 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -4053,6 +4053,9 @@ msgstr ""
msgid "AdminUsers|Projects, issues, merge requests, and comments of this user are hidden from other users."
msgstr ""
+msgid "AdminUsers|Re-enable spam monitoring for %{username}?"
+msgstr ""
+
msgid "AdminUsers|Reactivating a user will:"
msgstr ""
@@ -4092,9 +4095,15 @@ msgstr ""
msgid "AdminUsers|Sort by"
msgstr ""
+msgid "AdminUsers|Stop monitoring %{username} for possible spam?"
+msgstr ""
+
msgid "AdminUsers|The maximum compute minutes that jobs in this namespace can use on shared runners each month. Set 0 for unlimited. Set empty to inherit the global setting of %{minutes}"
msgstr ""
+msgid "AdminUsers|The user can create issues, notes, snippets, and merge requests that appear to be spam without being blocked."
+msgstr ""
+
msgid "AdminUsers|The user can't access git repositories."
msgstr ""
@@ -4125,6 +4134,12 @@ msgstr ""
msgid "AdminUsers|To confirm, type %{username}."
msgstr ""
+msgid "AdminUsers|Trust user"
+msgstr ""
+
+msgid "AdminUsers|Trusted"
+msgstr ""
+
msgid "AdminUsers|Unban user"
msgstr ""
@@ -4140,6 +4155,9 @@ msgstr ""
msgid "AdminUsers|Unlock user %{username}?"
msgstr ""
+msgid "AdminUsers|Untrust user"
+msgstr ""
+
msgid "AdminUsers|User administration"
msgstr ""
@@ -4173,6 +4191,9 @@ msgstr ""
msgid "AdminUsers|When banned:"
msgstr ""
+msgid "AdminUsers|When not being monitored for spam:"
+msgstr ""
+
msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account"
msgstr ""
@@ -4203,9 +4224,15 @@ msgstr ""
msgid "AdminUsers|You can ban their account in the future if necessary."
msgstr ""
+msgid "AdminUsers|You can trust this user in the future if necessary."
+msgstr ""
+
msgid "AdminUsers|You can unban their account in the future. Their data remains intact."
msgstr ""
+msgid "AdminUsers|You can untrust this user in the future."
+msgstr ""
+
msgid "AdminUsers|You cannot remove your own administrator access."
msgstr ""
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index ca08bc9e577da3c03a00262943dbb7e808bbf081..9ab5b1fd3bb8758cbc7ee70f1005d2bac377746b 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -82,4 +82,12 @@
end
end
end
+
+ it 'does not perform N+1 queries' do
+ control_queries = ActiveRecord::QueryRecorder.new { visit admin_users_path }
+
+ expect { create(:user) }.to change { User.count }.by(1)
+
+ expect { visit admin_users_path }.not_to exceed_query_limit(control_queries)
+ end
end
diff --git a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json
index 44d8e48a972c485fbc354521e5361a6091254f5c..61472b273e13422eb6ba7b28572daba32d316ae3 100644
--- a/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json
+++ b/spec/fixtures/api/schemas/entities/admin_users_data_attributes_paths.json
@@ -1,19 +1,51 @@
{
"type": "object",
"properties": {
- "edit": { "type": "string" },
- "approve": { "type": "string" },
- "reject": { "type": "string" },
- "unblock": { "type": "string" },
- "block": { "type": "string" },
- "deactivate": { "type": "string" },
- "activate": { "type": "string" },
- "unlock": { "type": "string" },
- "delete": { "type": "string" },
- "delete_with_contributions": { "type": "string" },
- "admin_user": { "type": "string" },
- "ban": { "type": "string" },
- "unban": { "type": "string" }
+ "edit": {
+ "type": "string"
+ },
+ "approve": {
+ "type": "string"
+ },
+ "reject": {
+ "type": "string"
+ },
+ "unblock": {
+ "type": "string"
+ },
+ "block": {
+ "type": "string"
+ },
+ "deactivate": {
+ "type": "string"
+ },
+ "activate": {
+ "type": "string"
+ },
+ "unlock": {
+ "type": "string"
+ },
+ "delete": {
+ "type": "string"
+ },
+ "delete_with_contributions": {
+ "type": "string"
+ },
+ "admin_user": {
+ "type": "string"
+ },
+ "ban": {
+ "type": "string"
+ },
+ "unban": {
+ "type": "string"
+ },
+ "trust": {
+ "type": "string"
+ },
+ "untrust": {
+ "type": "string"
+ }
},
"required": [
"edit",
@@ -28,7 +60,9 @@
"delete_with_contributions",
"admin_user",
"ban",
- "unban"
+ "unban",
+ "trust",
+ "untrust"
],
"additionalProperties": false
}
diff --git a/spec/frontend/admin/users/constants.js b/spec/frontend/admin/users/constants.js
index d341eb03b1b33c6426ae1a50b65be5fffbbebdf0..39e8e51f43ca0bb9b196d3346e06c185dc1f6458 100644
--- a/spec/frontend/admin/users/constants.js
+++ b/spec/frontend/admin/users/constants.js
@@ -9,6 +9,8 @@ const REJECT = 'reject';
const APPROVE = 'approve';
const BAN = 'ban';
const UNBAN = 'unban';
+const TRUST = 'trust';
+const UNTRUST = 'untrust';
export const EDIT = 'edit';
@@ -24,6 +26,8 @@ export const CONFIRMATION_ACTIONS = [
UNBAN,
APPROVE,
REJECT,
+ TRUST,
+ UNTRUST,
];
export const DELETE_ACTIONS = [DELETE, DELETE_WITH_CONTRIBUTIONS];
diff --git a/spec/helpers/admin/user_actions_helper_spec.rb b/spec/helpers/admin/user_actions_helper_spec.rb
index 87d2308690c6175136baf0ee9c9c36034f56f174..abfdabf3413df96be4a3821f5bd4d8b30368518f 100644
--- a/spec/helpers/admin/user_actions_helper_spec.rb
+++ b/spec/helpers/admin/user_actions_helper_spec.rb
@@ -2,7 +2,7 @@
require "spec_helper"
-RSpec.describe Admin::UserActionsHelper do
+RSpec.describe Admin::UserActionsHelper, feature_category: :user_management do
describe '#admin_actions' do
let_it_be(:current_user) { build(:user) }
@@ -29,13 +29,33 @@
context 'the user is a standard user' do
let_it_be(:user) { create(:user) }
- it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete", "delete_with_contributions") }
+ it do
+ is_expected.to contain_exactly(
+ "edit",
+ "block",
+ "ban",
+ "deactivate",
+ "delete",
+ "delete_with_contributions",
+ "trust"
+ )
+ end
end
context 'the user is an admin user' do
let_it_be(:user) { create(:user, :admin) }
- it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete", "delete_with_contributions") }
+ it do
+ is_expected.to contain_exactly(
+ "edit",
+ "block",
+ "ban",
+ "deactivate",
+ "delete",
+ "delete_with_contributions",
+ "trust"
+ )
+ end
end
context 'the user is blocked by LDAP' do
@@ -59,7 +79,16 @@
context 'the user is deactivated' do
let_it_be(:user) { create(:user, :deactivated) }
- it { is_expected.to contain_exactly("edit", "block", "ban", "activate", "delete", "delete_with_contributions") }
+ it do
+ is_expected.to contain_exactly(
+ "edit",
+ "block",
+ "ban",
+ "activate",
+ "delete",
+ "delete_with_contributions"
+ )
+ end
end
context 'the user is locked' do
@@ -77,7 +106,8 @@
"deactivate",
"unlock",
"delete",
- "delete_with_contributions"
+ "delete_with_contributions",
+ "trust"
)
}
end
@@ -88,6 +118,21 @@
it { is_expected.to contain_exactly("edit", "unban", "delete", "delete_with_contributions") }
end
+ context 'the user is trusted' do
+ let_it_be(:user) { create(:user, :trusted) }
+
+ it do
+ is_expected.to contain_exactly("edit",
+ "block",
+ "deactivate",
+ "ban",
+ "delete",
+ "delete_with_contributions",
+ "untrust"
+ )
+ end
+ end
+
context 'the current_user does not have permission to delete the user' do
let_it_be(:user) { build(:user) }
@@ -95,7 +140,7 @@
allow(helper).to receive(:can?).with(current_user, :destroy_user, user).and_return(false)
end
- it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate") }
+ it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "trust") }
end
context 'the user is a sole owner of a group' do
@@ -106,7 +151,7 @@
group.add_owner(user)
end
- it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete_with_contributions") }
+ it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete_with_contributions", "trust") }
end
context 'the user is a bot' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index c9da1a31c8686e516dfa233bb8086dd0806535f4..09eb92e01e6bf5d67e6e31eea593b0644c48099f 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -1419,6 +1419,16 @@
'ORDER BY "users"."current_sign_in_at" ASC NULLS LAST')
end
end
+
+ describe '.trusted' do
+ let_it_be(:trusted_user1) { create(:user, :trusted) }
+ let_it_be(:trusted_user2) { create(:user, :trusted) }
+ let_it_be(:user3) { create(:user) }
+
+ it 'returns only the trusted users' do
+ expect(described_class.trusted).to match_array([trusted_user1, trusted_user2])
+ end
+ end
end
context 'strip attributes' do
@@ -2858,6 +2868,12 @@
expect(described_class.filter_items('wop')).to include user
end
+
+ it 'filters by trusted' do
+ expect(described_class).to receive(:trusted).and_return([user])
+
+ expect(described_class.filter_items('trusted')).to include user
+ end
end
describe '.without_projects' do