Skip to content

Commit 770fe35

Browse files
authored
Merge pull request #1268 from Crown-Commercial-Service/feature/nrmi-289-update-user-email
Feature/nrmi 289 update user email
2 parents 49287ed + 529028e commit 770fe35

32 files changed

Lines changed: 850 additions & 2 deletions

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ gem 'net-http', '>= 0.4.0'
105105
gem 'sprockets-rails', '>= 3.5.1'
106106

107107
gem 'connection_pool', '< 3'
108+
gem 'notifications-ruby-client'
108109

109110
group :development, :test do
110111
gem 'brakeman', require: false

Gemfile.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ GEM
355355
nokogiri (1.19.2)
356356
mini_portile2 (~> 2.8.2)
357357
racc (~> 1.4)
358+
nokogiri (1.19.2-x86_64-linux-gnu)
359+
racc (~> 1.4)
360+
notifications-ruby-client (6.3.0)
361+
jwt (>= 1.5, < 4)
358362
oauth2 (2.0.18)
359363
faraday (>= 0.17.3, < 4.0)
360364
jwt (>= 1.0, < 4.0)
@@ -628,7 +632,9 @@ GEM
628632
zeitwerk (2.7.5)
629633

630634
PLATFORMS
635+
aarch64-linux-musl
631636
ruby
637+
x86_64-linux
632638

633639
DEPENDENCIES
634640
aasm
@@ -666,6 +672,7 @@ DEPENDENCIES
666672
mutex_m (~> 0.3.0)
667673
net-http (>= 0.4.0)
668674
nokogiri (>= 1.18.9)
675+
notifications-ruby-client
669676
omniauth-google-oauth2 (>= 1.2.2)
670677
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.0)
671678
parslet

app/controllers/admin/users_controller.rb

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
class Admin::UsersController < AdminController
2-
before_action :find_user, only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy]
2+
before_action :find_user,
3+
only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy edit_email
4+
update_email]
35

46
def index
57
@users = User.search(params[:search]).page(params[:page])
@@ -76,6 +78,26 @@ def update
7678
redirect_to admin_user_path(@user)
7779
end
7880

81+
def edit_email; end
82+
83+
def update_email
84+
new_email = user_params[:email]
85+
if new_email == @user.email
86+
flash[:notice] = I18n.t('errors.messages.error_updating_same_user_email_in_auth0')
87+
redirect_to admin_user_path(@user)
88+
return
89+
end
90+
91+
result = UpdateUserEmail.new(@user, new_email)
92+
if result.call
93+
flash[:notice] = I18n.t('errors.messages.success_updating_user_email_in_auth0')
94+
elsif result.errors
95+
flash[:alert] =
96+
I18n.t('errors.messages.error_updating_user_email_in_auth0') + ": #{result.errors.full_messages.join(', ')}"
97+
end
98+
redirect_to admin_user_path(@user)
99+
end
100+
79101
def confirm_delete; end
80102

81103
def confirm_reactivate; end
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
module V1
2+
class EmailVerificationsController < ApiController
3+
skip_before_action :reject_without_user!, only: [:verification]
4+
5+
def create
6+
user = User.find_by(auth_id: current_auth_id)
7+
new_email = params.dig('_jsonapi', 'new_email')
8+
9+
verification_request = verification_request(user, new_email)
10+
11+
if verification_request.save
12+
13+
SendEmailVerificationJob.perform_later(
14+
new_email: new_email,
15+
verification_url: verification_request.verification_url,
16+
person_name: user.name
17+
)
18+
19+
render jsonapi: verification_request, status: :ok, context: { request: request }
20+
else
21+
render jsonapi_errors: verification_request.errors, status: :unprocessable_entity
22+
end
23+
end
24+
25+
def verification
26+
token = find_token_params || (return render_invalid_token_error)
27+
email_change_request = EmailChangeRequest.find_by(token: token) || (return render_invalid_token_error)
28+
update_user_email = update_email(email_change_request)
29+
email_change_request.update(used_at: Time.current, active: false)
30+
31+
return render jsonapi: email_change_request.user, status: :ok if update_user_email&.call
32+
33+
error_body = verification_request&.errors ||
34+
{ error: I18n.t('email_verifications.invalid_or_expired_token') }
35+
render jsonapi_errors: error_body, status: :unprocessable_entity
36+
end
37+
38+
def active
39+
user = User.find_by(auth_id: current_auth_id)
40+
unless user
41+
render jsonapi_errors: { error: 'User not found' }, status: :not_found
42+
return
43+
end
44+
45+
verification = EmailChangeRequest.where(user: user, active: true).order(created_at: :desc).first
46+
47+
if verification
48+
render jsonapi: verification, status: :ok, context: { request: request }
49+
else
50+
render jsonapi_errors: { error: 'No active email verification found' }, status: :not_found
51+
end
52+
end
53+
54+
def cancel_pending_email_change
55+
user = User.find_by(auth_id: current_auth_id)
56+
unless user
57+
render jsonapi_errors: { error: 'User not found' }, status: :not_found
58+
return
59+
end
60+
61+
EmailChangeRequest.where(user: user, active: true).find_each do |record|
62+
record.update(active: false)
63+
end
64+
65+
head :no_content
66+
end
67+
68+
private
69+
70+
def verification_request(user, new_email)
71+
expires_at = 2.days.from_now
72+
73+
EmailChangeRequest.where(user: user, active: true).find_each do |record|
74+
record.update(active: false)
75+
end
76+
77+
EmailChangeRequest.new(
78+
user: user,
79+
new_email: new_email,
80+
expires_at: expires_at
81+
)
82+
end
83+
84+
def update_email(email_change_request)
85+
UpdateUserEmail.new(email_change_request.user, email_change_request.new_email)
86+
end
87+
88+
def render_invalid_token_error
89+
render jsonapi_errors: ApiMessage.new({ message: I18n.t('email_verifications.invalid_or_expired_token') }),
90+
status: :unprocessable_entity
91+
end
92+
93+
def find_token_params
94+
params.dig('_jsonapi', 'token')
95+
end
96+
end
97+
end

app/controllers/v1/users_controller.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,60 @@ def update_name
1717
status: :unprocessable_entity
1818
end
1919
end
20+
21+
def update_email
22+
user = User.find_by(auth_id: current_auth_id)
23+
new_email = params.dig('_jsonapi', 'email')
24+
25+
if new_email.blank?
26+
render jsonapi_errors: { email: ['is required'] }, status: :unprocessable_entity
27+
return
28+
end
29+
30+
verification_request = verification_request(user, new_email)
31+
32+
if verification_request.save
33+
notify_result = SendEmailVerificationJob.perform_later(
34+
new_email: new_email,
35+
verification_url: verification_request.verification_url,
36+
person_name: user.name
37+
)
38+
39+
if notify_result == false
40+
render jsonapi_errors: { notify: ['Failed to send email'] }, status: :unprocessable_entity
41+
return
42+
end
43+
44+
render jsonapi: verification_request, status: :ok, context: { request: request }
45+
else
46+
render jsonapi_errors: verification_request.errors.presence || { base: ['Failed to save verification request'] },
47+
status: :unprocessable_entity
48+
end
49+
end
50+
51+
def verification_request(user, new_email)
52+
token = SecureRandom.hex(24)
53+
expires_at = 2.days.from_now
54+
55+
EmailChangeRequest.where(user: user, active: true).find_each do |record|
56+
record.update(active: false)
57+
end
58+
59+
EmailChangeRequest.new(
60+
user: user,
61+
new_email: new_email,
62+
token: token,
63+
expires_at: expires_at
64+
)
65+
end
66+
67+
def user_auth_logs
68+
user = User.find_by!(auth_id: current_auth_id)
69+
auth_logs = UserLogsInAuth0.new(user: user).call
70+
objects = auth_logs.map do |log|
71+
OpenStruct.new(log.merge('id' => SecureRandom.uuid))
72+
end
73+
74+
render jsonapi: objects, class: { OpenStruct: SerializableUserAuthLog }, status: :ok
75+
end
2076
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class SendConfirmEmailVerificationJob < ApplicationJob
2+
queue_as :default
3+
4+
TEMPLATE_ID = '59cda9d1-9a70-4196-8b50-fba71e410765'.freeze
5+
6+
def perform(new_email:, person_name: nil)
7+
Notify.new.send_email(
8+
template_id: TEMPLATE_ID,
9+
email: new_email,
10+
vars: {
11+
login_url: ENV['FRONTEND_URL'],
12+
email_address: new_email,
13+
person_name: person_name
14+
}
15+
)
16+
end
17+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
class SendEmailVerificationJob < ApplicationJob
2+
queue_as :default
3+
4+
TEMPLATE_ID = '26164cdb-915b-4e13-97e9-d4a42367f068'.freeze
5+
6+
def perform(new_email:, verification_url:, person_name: nil)
7+
Notify.new.send_email(
8+
template_id: TEMPLATE_ID,
9+
email: new_email,
10+
vars: {
11+
verify_url: verification_url,
12+
new_email: new_email,
13+
person_name: person_name
14+
}
15+
)
16+
end
17+
end

app/models/api_message.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
class ApiMessage
2+
def initialize(attributes = {})
3+
@attributes = attributes.transform_keys(&:to_sym)
4+
@attributes[:id] ||= SecureRandom.uuid
5+
end
6+
7+
def id
8+
@attributes[:id]
9+
end
10+
11+
def as_json(*_args)
12+
@attributes
13+
end
14+
15+
def method_missing(method, *args, &block)
16+
if @attributes.key?(method)
17+
@attributes[method]
18+
else
19+
super
20+
end
21+
end
22+
23+
def respond_to_missing?(method, include_private = false)
24+
@attributes.key?(method) || super
25+
end
26+
end

app/models/email_change_request.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
class EmailChangeRequest < ApplicationRecord
2+
has_secure_token :token, length: 64
3+
belongs_to :user
4+
5+
validates :new_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
6+
validates :expires_at, presence: true
7+
validate :new_email_not_taken
8+
validate :email_not_verified
9+
validate :verifiable, on: :update
10+
11+
def self.verified_email(user, new_email)
12+
where(user: user, new_email: new_email, active: false)
13+
.where.not(used_at: nil)
14+
.order(used_at: :desc)
15+
.first
16+
end
17+
18+
def email_not_verified
19+
return if self.class.verified_email(user, new_email).blank?
20+
21+
errors.add(:new_email, I18n.t('email_verifications.already_verified'))
22+
end
23+
24+
def verification_url
25+
"#{ENV['FRONTEND_URL']}/email/verification/#{token}"
26+
end
27+
28+
def verifiable
29+
used_at.nil? && expires_at > Time.current
30+
end
31+
32+
private
33+
34+
def new_email_not_taken
35+
return unless User.exists?(email: new_email)
36+
37+
errors.add(:new_email, 'is already taken')
38+
end
39+
end

app/models/user.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
require './lib/auth0_api'
22

33
class User < ApplicationRecord
4+
has_one :email_change_request, lambda {
5+
where(active: true).order(created_at: :desc)
6+
}, class_name: 'EmailChangeRequest', dependent: :destroy, inverse_of: :user
47
has_many :memberships, dependent: :destroy
58
has_many :suppliers, through: :memberships
69
has_many :submissions, through: :suppliers

0 commit comments

Comments
 (0)