diff --git a/app/graphql/mutations/users/set_namespace_commit_email.rb b/app/graphql/mutations/users/set_namespace_commit_email.rb
new file mode 100644
index 0000000000000000000000000000000000000000..72ef0635bb30c133cfab7450677bb496e9b884ce
--- /dev/null
+++ b/app/graphql/mutations/users/set_namespace_commit_email.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Mutations
+ module Users
+ class SetNamespaceCommitEmail < BaseMutation
+ graphql_name 'UserSetNamespaceCommitEmail'
+
+ argument :namespace_id,
+ ::Types::GlobalIDType[::Namespace],
+ required: true,
+ description: 'ID of the namespace to set the namespace commit email for.'
+
+ argument :email_id,
+ ::Types::GlobalIDType[::Email],
+ required: false,
+ description: 'ID of the email to set.'
+
+ field :namespace_commit_email,
+ Types::Users::NamespaceCommitEmailType,
+ null: true,
+ description: 'User namespace commit email after mutation.'
+
+ authorize :read_namespace
+
+ def resolve(args)
+ namespace = authorized_find!(args[:namespace_id])
+ args[:email_id] = args[:email_id].model_id
+
+ result = ::Users::SetNamespaceCommitEmailService.new(current_user, namespace, args[:email_id], {}).execute
+ {
+ namespace_commit_email: result.payload[:namespace_commit_email],
+ errors: result.errors
+ }
+ end
+
+ private
+
+ def find_object(id)
+ GitlabSchema.object_from_id(
+ id, expected_type: [::Namespace, ::Namespaces::UserNamespace, ::Namespaces::ProjectNamespace]).sync
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index efc7bf89693e2e67d313a572649b006880d47435..c3e44eb65e7fa86a62bbde069fee02b3e715fdfc 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -181,6 +181,7 @@ class MutationType < BaseObject
mount_mutation Mutations::Pages::MarkOnboardingComplete
mount_mutation Mutations::SavedReplies::Destroy
mount_mutation Mutations::Uploads::Delete
+ mount_mutation Mutations::Users::SetNamespaceCommitEmail
end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index c5c0353e6f438f72ae106345e42904effb16563c..76b2bde0f22cc780bb0d46a64e6a18d8e826df8e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2298,6 +2298,12 @@ def abuse_metadata
}
end
+ def namespace_commit_email_for_namespace(namespace)
+ return if namespace.nil?
+
+ namespace_commit_emails.find_by(namespace: namespace)
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 1078eda38e7e1188593f843895cbc45172b2b66f..2fd198b8cf4593dceb24708bba0550c95d9ecc03 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -31,6 +31,7 @@ class UserPolicy < BasePolicy
enable :read_user_groups
enable :read_saved_replies
enable :read_user_email_address
+ enable :admin_user_email_address
end
rule { default }.enable :read_user_profile
diff --git a/app/services/users/set_namespace_commit_email_service.rb b/app/services/users/set_namespace_commit_email_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..30ee597120deed40149ada7dbabeaba166132066
--- /dev/null
+++ b/app/services/users/set_namespace_commit_email_service.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module Users
+ class SetNamespaceCommitEmailService
+ include Gitlab::Allowable
+
+ attr_reader :current_user, :target_user, :namespace, :email_id
+
+ def initialize(current_user, namespace, email_id, params)
+ @current_user = current_user
+ @target_user = params.delete(:user) || current_user
+ @namespace = namespace
+ @email_id = email_id
+ end
+
+ def execute
+ return error(_('Namespace must be provided.')) if namespace.nil?
+
+ unless can?(current_user, :admin_user_email_address, target_user)
+ return error(_("User doesn't exist or you don't have permission to change namespace commit emails."))
+ end
+
+ unless can?(target_user, :read_namespace, namespace)
+ return error(_("Namespace doesn't exist or you don't have permission."))
+ end
+
+ email = target_user.emails.find_by(id: email_id) unless email_id.nil? # rubocop: disable CodeReuse/ActiveRecord
+ existing_namespace_commit_email = target_user.namespace_commit_email_for_namespace(namespace)
+ if existing_namespace_commit_email.nil?
+ return error(_('Email must be provided.')) if email.nil?
+
+ create_namespace_commit_email(email)
+ elsif email_id.nil?
+ remove_namespace_commit_email(existing_namespace_commit_email)
+ else
+ update_namespace_commit_email(existing_namespace_commit_email, email)
+ end
+ end
+
+ private
+
+ def remove_namespace_commit_email(namespace_commit_email)
+ namespace_commit_email.destroy
+ success(nil)
+ end
+
+ def create_namespace_commit_email(email)
+ namespace_commit_email = ::Users::NamespaceCommitEmail.new(
+ user: target_user,
+ namespace: namespace,
+ email: email
+ )
+
+ save_namespace_commit_email(namespace_commit_email)
+ end
+
+ def update_namespace_commit_email(namespace_commit_email, email)
+ namespace_commit_email.email = email
+
+ save_namespace_commit_email(namespace_commit_email)
+ end
+
+ def save_namespace_commit_email(namespace_commit_email)
+ if !namespace_commit_email.save
+ error_in_save(namespace_commit_email)
+ else
+ success(namespace_commit_email)
+ end
+ end
+
+ def success(namespace_commit_email)
+ ServiceResponse.success(payload: {
+ namespace_commit_email: namespace_commit_email
+ })
+ end
+
+ def error(message)
+ ServiceResponse.error(message: message)
+ end
+
+ def error_in_save(namespace_commit_email)
+ return error(_('Failed to save namespace commit email.')) if namespace_commit_email.errors.empty?
+
+ error(namespace_commit_email.errors.full_messages.to_sentence)
+ end
+ end
+end
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 7661f2ec9a625afb3458b6bf05dfd4f4c2e4ada4..25fa02ed74eba2713c3681135f3c1ac53d29d2be 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -6736,6 +6736,26 @@ Input type: `UserPreferencesUpdateInput`
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `userPreferences` | [`UserPreferences`](#userpreferences) | User preferences after mutation. |
+### `Mutation.userSetNamespaceCommitEmail`
+
+Input type: `UserSetNamespaceCommitEmailInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `emailId` | [`EmailID`](#emailid) | ID of the email to set. |
+| `namespaceId` | [`NamespaceID!`](#namespaceid) | ID of the namespace to set the namespace commit email for. |
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
+| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
+| `namespaceCommitEmail` | [`NamespaceCommitEmail`](#namespacecommitemail) | User namespace commit email after mutation. |
+
### `Mutation.vulnerabilityConfirm`
Input type: `VulnerabilityConfirmInput`
@@ -26890,6 +26910,12 @@ Duration between two instants, represented as a fractional number of seconds.
For example: 12.3334.
+### `EmailID`
+
+A `EmailID` is a global ID. It is encoded as a string.
+
+An example `EmailID` is: `"gid://gitlab/Email/1"`.
+
### `EnvironmentID`
A `EnvironmentID` is a global ID. It is encoded as a string.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 313e37cb81a91bc14ac53e861c997afc3d3a10d0..cf20c75e331731916b3dfb845d28a6e9aa50a682 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16769,6 +16769,9 @@ msgstr ""
msgid "Email display name"
msgstr ""
+msgid "Email must be provided."
+msgstr ""
+
msgid "Email not verified. Please verify your email in Salesforce."
msgstr ""
@@ -18773,6 +18776,9 @@ msgstr ""
msgid "Failed to save merge conflicts resolutions. Please try again!"
msgstr ""
+msgid "Failed to save namespace commit email."
+msgstr ""
+
msgid "Failed to save new settings"
msgstr ""
@@ -29669,6 +29675,12 @@ msgstr ""
msgid "Namespace Limits"
msgstr ""
+msgid "Namespace doesn't exist or you don't have permission."
+msgstr ""
+
+msgid "Namespace must be provided."
+msgstr ""
+
msgid "Namespace or group to import repository into does not exist."
msgstr ""
@@ -49310,6 +49322,9 @@ msgstr ""
msgid "User does not have permission to create a Security Policy project."
msgstr ""
+msgid "User doesn't exist or you don't have permission to change namespace commit emails."
+msgstr ""
+
msgid "User has already been deactivated"
msgstr ""
diff --git a/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb b/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6d8e15ac791e6fbcf05b0ede636f7a7b4a4b7b62
--- /dev/null
+++ b/spec/graphql/mutations/users/set_namespace_commit_email_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Users::SetNamespaceCommitEmail, feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group) }
+ let(:email) { create(:email, user: current_user) }
+ let(:input) { {} }
+ let(:namespace_id) { group.to_global_id }
+ let(:email_id) { email.to_global_id }
+
+ shared_examples 'success' do
+ it 'creates namespace commit email with correct values' do
+ expect(resolve_mutation[:namespace_commit_email])
+ .to have_attributes({ namespace_id: namespace_id.model_id.to_i, email_id: email_id.model_id.to_i })
+ end
+ end
+
+ describe '#resolve' do
+ subject(:resolve_mutation) do
+ described_class.new(object: nil, context: { current_user: current_user }, field: nil).resolve(
+ namespace_id: namespace_id,
+ email_id: email_id
+ )
+ end
+
+ context 'when current_user does not have permission' do
+ it 'raises an error' do
+ expect { resolve_mutation }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ .with_message(Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR)
+ end
+ end
+
+ context 'when the user has permission' do
+ before do
+ group.add_reporter(current_user)
+ end
+
+ context 'when the email does not belong to the target user' do
+ let(:email_id) { create(:email).to_global_id }
+
+ it 'returns the validation error' do
+ expect(resolve_mutation[:errors]).to contain_exactly("Email must be provided.")
+ end
+ end
+
+ context 'when namespace is a group' do
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a user' do
+ let(:namespace_id) { current_user.namespace.to_global_id }
+
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a project' do
+ let_it_be(:project) { create(:project) }
+
+ let(:namespace_id) { project.project_namespace.to_global_id }
+
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it_behaves_like 'success'
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_namespace) }
+end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 94b7e2951671f6ef97fd8fef699ae69e824c46b8..9a2caeb74356f72340b2e70ac514d06b0a01b75e 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -253,10 +253,12 @@
context 'when admin mode is enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:read_user_email_address) }
+ it { is_expected.to be_allowed(:admin_user_email_address) }
end
context 'when admin mode is disabled' do
it { is_expected.not_to be_allowed(:read_user_email_address) }
+ it { is_expected.not_to be_allowed(:admin_user_email_address) }
end
end
@@ -265,10 +267,12 @@
subject { described_class.new(current_user, current_user) }
it { is_expected.to be_allowed(:read_user_email_address) }
+ it { is_expected.to be_allowed(:admin_user_email_address) }
end
context "requesting a different user's" do
it { is_expected.not_to be_allowed(:read_user_email_address) }
+ it { is_expected.not_to be_allowed(:admin_user_email_address) }
end
end
end
diff --git a/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb b/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1db6f83ce4fe559477ff78b5c88c74dffe1e09c3
--- /dev/null
+++ b/spec/requests/api/graphql/users/set_namespace_commit_email_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Setting namespace commit email', feature_category: :user_profile do
+ include GraphqlHelpers
+
+ let(:current_user) { create(:user) }
+ let(:group) { create(:group, :public) }
+ let(:email) { create(:email, :confirmed, user: current_user) }
+ let(:input) { {} }
+ let(:namespace_id) { group.to_global_id }
+ let(:email_id) { email.to_global_id }
+
+ let(:resource_or_permission_error) do
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+ end
+
+ let(:mutation) do
+ variables = {
+ namespace_id: namespace_id,
+ email_id: email_id
+ }
+ graphql_mutation(:user_set_namespace_commit_email, variables.merge(input),
+ <<-QL.strip_heredoc
+ namespaceCommitEmail {
+ email {
+ id
+ }
+ }
+ errors
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:user_set_namespace_commit_email)
+ end
+
+ shared_examples 'success' do
+ it 'creates a namespace commit email' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response.dig('namespaceCommitEmail', 'email', 'id')).to eq(email.to_global_id.to_s)
+ expect(graphql_errors).to be_nil
+ end
+ end
+
+ before do
+ group.add_reporter(current_user)
+ end
+
+ context 'when current_user is nil' do
+ it 'returns the top level error' do
+ post_graphql_mutation(mutation, current_user: nil)
+
+ expect(graphql_errors.first).to match a_hash_including(
+ 'message' => resource_or_permission_error)
+ end
+ end
+
+ context 'when the user cannot access the namespace' do
+ let(:namespace_id) { create(:group).to_global_id }
+
+ it 'returns the top level error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors).not_to be_empty
+ expect(graphql_errors.first).to match a_hash_including(
+ 'message' => resource_or_permission_error)
+ end
+ end
+
+ context 'when the service returns an error' do
+ let(:email_id) { create(:email).to_global_id }
+
+ it 'returns the error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response['errors']).to contain_exactly("Email must be provided.")
+ expect(mutation_response['namespaceCommitEmail']).to be_nil
+ end
+ end
+
+ context 'when namespace is a group' do
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a user' do
+ let(:namespace_id) { current_user.namespace.to_global_id }
+
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a project' do
+ let_it_be(:project) { create(:project) }
+
+ let(:namespace_id) { project.project_namespace.to_global_id }
+
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it_behaves_like 'success'
+ end
+end
diff --git a/spec/services/users/set_namespace_commit_email_service_spec.rb b/spec/services/users/set_namespace_commit_email_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4f64d454ecb507534af4c5cce6288d4f6acf1552
--- /dev/null
+++ b/spec/services/users/set_namespace_commit_email_service_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::SetNamespaceCommitEmailService, feature_category: :user_profile do
+ include AfterNextHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:email) { create(:email, user: user) }
+ let_it_be(:existing_achievement) { create(:achievement, namespace: group) }
+
+ let(:namespace) { group }
+ let(:current_user) { user }
+ let(:target_user) { user }
+ let(:email_id) { email.id }
+ let(:params) { { user: target_user } }
+ let(:service) { described_class.new(current_user, namespace, email_id, params) }
+
+ before_all do
+ group.add_reporter(user)
+ end
+
+ shared_examples 'success' do
+ it 'creates namespace commit email' do
+ result = service.execute
+
+ expect(result.payload[:namespace_commit_email]).to be_a(Users::NamespaceCommitEmail)
+ expect(result.payload[:namespace_commit_email]).to be_persisted
+ end
+ end
+
+ describe '#execute' do
+ context 'when current_user is not provided' do
+ let(:current_user) { nil }
+
+ it 'returns error message' do
+ expect(service.execute.message)
+ .to eq("User doesn't exist or you don't have permission to change namespace commit emails.")
+ end
+ end
+
+ context 'when current_user does not have permission to change namespace commit emails' do
+ let(:target_user) { create(:user) }
+
+ it 'returns error message' do
+ expect(service.execute.message)
+ .to eq("User doesn't exist or you don't have permission to change namespace commit emails.")
+ end
+ end
+
+ context 'when target_user does not have permission to access the namespace' do
+ let(:namespace) { create(:group) }
+
+ it 'returns error message' do
+ expect(service.execute.message).to eq("Namespace doesn't exist or you don't have permission.")
+ end
+ end
+
+ context 'when namespace is not provided' do
+ let(:namespace) { nil }
+
+ it 'returns error message' do
+ expect(service.execute.message).to eq('Namespace must be provided.')
+ end
+ end
+
+ context 'when target user is not current user' do
+ context 'when current user is an admin' do
+ let(:current_user) { create(:user, :admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'creates namespace commit email' do
+ result = service.execute
+
+ expect(result.payload[:namespace_commit_email]).to be_a(Users::NamespaceCommitEmail)
+ expect(result.payload[:namespace_commit_email]).to be_persisted
+ end
+ end
+
+ context 'when admin mode is not enabled' do
+ it 'returns error message' do
+ expect(service.execute.message)
+ .to eq("User doesn't exist or you don't have permission to change namespace commit emails.")
+ end
+ end
+ end
+
+ context 'when current user is not an admin' do
+ let(:current_user) { create(:user) }
+
+ it 'returns error message' do
+ expect(service.execute.message)
+ .to eq("User doesn't exist or you don't have permission to change namespace commit emails.")
+ end
+ end
+ end
+
+ context 'when namespace commit email does not exist' do
+ context 'when email_id is not provided' do
+ let(:email_id) { nil }
+
+ it 'returns error message' do
+ expect(service.execute.message).to eq('Email must be provided.')
+ end
+ end
+
+ context 'when model save fails' do
+ before do
+ allow_next(::Users::NamespaceCommitEmail).to receive(:save).and_return(false)
+ end
+
+ it 'returns error message' do
+ expect(service.execute.message).to eq('Failed to save namespace commit email.')
+ end
+ end
+
+ context 'when namepsace is a group' do
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a user' do
+ let(:namespace) { current_user.namespace }
+
+ it_behaves_like 'success'
+ end
+
+ context 'when namespace is a project' do
+ let_it_be(:project) { create(:project) }
+
+ let(:namespace) { project.project_namespace }
+
+ before do
+ project.add_reporter(current_user)
+ end
+
+ it_behaves_like 'success'
+ end
+ end
+
+ context 'when namespace commit email already exists' do
+ let!(:existing_namespace_commit_email) do
+ create(:namespace_commit_email,
+ user: target_user,
+ namespace: namespace,
+ email: create(:email, user: target_user))
+ end
+
+ context 'when email_id is not provided' do
+ let(:email_id) { nil }
+
+ it 'destroys the namespace commit email' do
+ result = service.execute
+
+ expect(result.message).to be_nil
+ expect(result.payload[:namespace_commit_email]).to be_nil
+ end
+ end
+
+ context 'and email_id is provided' do
+ let(:email_id) { create(:email, user: current_user).id }
+
+ it 'updates namespace commit email' do
+ result = service.execute
+
+ existing_namespace_commit_email.reload
+
+ expect(result.payload[:namespace_commit_email]).to eq(existing_namespace_commit_email)
+ expect(existing_namespace_commit_email.email_id).to eq(email_id)
+ end
+ end
+
+ context 'when model save fails' do
+ before do
+ allow_any_instance_of(::Users::NamespaceCommitEmail).to receive(:save).and_return(false) # rubocop:disable RSpec/AnyInstanceOf
+ end
+
+ it 'returns generic error message' do
+ expect(service.execute.message).to eq('Failed to save namespace commit email.')
+ end
+
+ context 'with model errors' do
+ before do
+ allow_any_instance_of(::Users::NamespaceCommitEmail).to receive_message_chain(:errors, :empty?).and_return(false) # rubocop:disable RSpec/AnyInstanceOf
+ allow_any_instance_of(::Users::NamespaceCommitEmail).to receive_message_chain(:errors, :full_messages, :to_sentence).and_return('Model error') # rubocop:disable RSpec/AnyInstanceOf
+ end
+
+ it 'returns the model error message' do
+ expect(service.execute.message).to eq('Model error')
+ end
+ end
+ end
+ end
+ end
+end