From 272c63bcedde96485c112dd8a0aad61c64cb0a9b Mon Sep 17 00:00:00 2001 From: mo-zag Date: Mon, 13 Apr 2026 20:28:33 +0100 Subject: [PATCH 01/11] Add email change & verification flow Implements a full email change verification flow for users and admins. Adds EmailChangeRequest model, migrations and schema entries (token, expires_at, used_at, active) and ApiMessage helper. Exposes API endpoints (v1/email_verifications#create, verification, active, cancel_pending_email_change) and v1/users#update_email; adds admin UI/actions to edit/update user email. Introduces background jobs to send verification/confirmation emails via GOV.UK Notify (SendEmailVerificationJob, SendConfirmEmailVerificationJob) and Notify service wrapper. Adds services to update local user email and sync with Auth0 (UpdateUserEmail, UpdateUserEmailInAuth0) and to fetch auth logs (UserLogsInAuth0). Includes JSONAPI serializable resources, controller logic, view, route updates and comprehensive specs for models, controllers and services. Token expiry is 2 days; update operations are wrapped in DB transactions and Auth0 sync errors roll back changes. --- app/controllers/admin/users_controller.rb | 23 ++- .../v1/email_verifications_controller.rb | 96 +++++++++++ app/controllers/v1/users_controller.rb | 55 ++++++ .../send_confirm_email_verification_job.rb | 16 ++ app/jobs/send_email_verification_job.rb | 16 ++ app/models/api_message.rb | 26 +++ app/models/email_change_request.rb | 40 +++++ app/models/user.rb | 3 + app/serializable/serializable_api_message.rb | 9 + .../serializable_email_change_request.rb | 4 + .../serializable_user_auth_log.rb | 13 ++ app/services/notify.rb | 18 ++ app/services/update_user_email.rb | 34 ++++ app/services/update_user_email_in_auth0.rb | 17 ++ app/services/user_logs_in_auth0.rb | 24 +++ app/views/admin/users/edit_email.html.haml | 12 ++ app/views/admin/users/show.html.haml | 14 ++ config/locales/en.yml | 9 +- config/routes.rb | 10 ++ ...0321120000_create_email_change_requests.rb | 14 ++ ...000_add_active_to_email_change_requests.rb | 6 + db/schema.rb | 159 ++++++++++-------- spec/factories/email_change_requests.rb | 10 ++ spec/models/email_change_request_spec.rb | 88 ++++++++++ spec/requests/admin/users_controller_spec.rb | 39 +++++ .../v1/email_verifications_controller_spec.rb | 70 ++++++++ spec/requests/v1/users_spec.rb | 56 ++++++ .../update_user_email_in_auth0_spec.rb | 21 +++ spec/services/update_user_email_spec.rb | 57 +++++++ spec/services/user_logs_in_auth0_spec.rb | 24 +++ 30 files changed, 910 insertions(+), 73 deletions(-) create mode 100644 app/controllers/v1/email_verifications_controller.rb create mode 100644 app/jobs/send_confirm_email_verification_job.rb create mode 100644 app/jobs/send_email_verification_job.rb create mode 100644 app/models/api_message.rb create mode 100644 app/models/email_change_request.rb create mode 100644 app/serializable/serializable_api_message.rb create mode 100644 app/serializable/serializable_email_change_request.rb create mode 100644 app/serializable/serializable_user_auth_log.rb create mode 100644 app/services/notify.rb create mode 100644 app/services/update_user_email.rb create mode 100644 app/services/update_user_email_in_auth0.rb create mode 100644 app/services/user_logs_in_auth0.rb create mode 100644 app/views/admin/users/edit_email.html.haml create mode 100644 db/migrate/20260321120000_create_email_change_requests.rb create mode 100644 db/migrate/20260322120000_add_active_to_email_change_requests.rb create mode 100644 spec/factories/email_change_requests.rb create mode 100644 spec/models/email_change_request_spec.rb create mode 100644 spec/requests/admin/users_controller_spec.rb create mode 100644 spec/requests/v1/email_verifications_controller_spec.rb create mode 100644 spec/services/update_user_email_in_auth0_spec.rb create mode 100644 spec/services/update_user_email_spec.rb create mode 100644 spec/services/user_logs_in_auth0_spec.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index f3fb89a26..eedb1d63d 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,5 +1,7 @@ class Admin::UsersController < AdminController - before_action :find_user, only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy] + before_action :find_user, + only: %i[show edit update reactivate_user confirm_delete confirm_reactivate destroy edit_email + update_email] def index @users = User.search(params[:search]).page(params[:page]) @@ -76,6 +78,25 @@ def update redirect_to admin_user_path(@user) end + def edit_email; end + + def update_email + new_email = user_params[:email] + if new_email == @user.email + flash[:notice] = I18n.t('errors.messages.error_updating_same_user_email_in_auth0') + redirect_to admin_user_path(@user) + return + end + + result = UpdateUserEmail.new(@user, new_email) + if result.call + flash[:notice] = I18n.t('errors.messages.success_updating_user_email_in_auth0') + elsif result.failure? + flash[:alert] = I18n.t('errors.messages.error_updating_user_email_in_auth0') + end + redirect_to admin_user_path(@user) + end + def confirm_delete; end def confirm_reactivate; end diff --git a/app/controllers/v1/email_verifications_controller.rb b/app/controllers/v1/email_verifications_controller.rb new file mode 100644 index 000000000..01aa12360 --- /dev/null +++ b/app/controllers/v1/email_verifications_controller.rb @@ -0,0 +1,96 @@ +module V1 + class EmailVerificationsController < ApiController + skip_before_action :reject_without_user!, only: [:verification] + + def create + user = User.find_by(auth_id: current_auth_id) + new_email = params.dig('_jsonapi', 'new_email') + + verification_request = verification_request(user, new_email) + + if verification_request.save + + SendEmailVerificationJob.perform_later( + email: new_email, + verification_url: verification_request.verification_url + ) + + render jsonapi: verification_request, status: :ok, context: { request: request } + else + render jsonapi_errors: verification_request.errors, status: :unprocessable_entity + end + end + + def verification + token = find_token_params || (return render_invalid_token_error) + email_change_request = EmailChangeRequest.find_by(token: token) || (return render_invalid_token_error) + update_user_email = update_email(email_change_request) + email_change_request.update(used_at: Time.current, active: false) + + return render jsonapi: email_change_request.user, status: :ok if update_user_email&.call + + error_body = verification_request&.errors || + { error: I18n.t('email_verifications.invalid_or_expired_token') } + render jsonapi_errors: error_body, status: :unprocessable_entity + end + + def active + user = User.find_by(auth_id: current_auth_id) + unless user + render jsonapi_errors: { error: 'User not found' }, status: :not_found + return + end + + verification = EmailChangeRequest.where(user: user, active: true).order(created_at: :desc).first + + if verification + render jsonapi: verification, status: :ok, context: { request: request } + else + render jsonapi_errors: { error: 'No active email verification found' }, status: :not_found + end + end + + def cancel_pending_email_change + user = User.find_by(auth_id: current_auth_id) + unless user + render jsonapi_errors: { error: 'User not found' }, status: :not_found + return + end + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + head :no_content + end + + private + + def verification_request(user, new_email) + expires_at = 2.days.from_now + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + EmailChangeRequest.new( + user: user, + new_email: new_email, + expires_at: expires_at + ) + end + + def update_email(email_change_request) + UpdateUserEmail.new(email_change_request.user, email_change_request.new_email) + end + + def render_invalid_token_error + render jsonapi_errors: ApiMessage.new({ message: I18n.t('email_verifications.invalid_or_expired_token') }), + status: :unprocessable_entity + end + + def find_token_params + params.dig('_jsonapi', 'token') + end + end +end diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb index 03603e28b..62f4ff45a 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/v1/users_controller.rb @@ -17,4 +17,59 @@ def update_name status: :unprocessable_entity end end + + def update_email + user = User.find_by(auth_id: current_auth_id) + new_email = params.dig('_jsonapi', 'email') + + if new_email.blank? + render jsonapi_errors: { email: ['is required'] }, status: :unprocessable_entity + return + end + + verification_request = verification_request(user, new_email) + + if verification_request.save + notify_result = SendEmailVerificationJob.perform_later( + email: new_email, + verification_url: verification_request.verification_url + ) + + if notify_result == false + render jsonapi_errors: { notify: ['Failed to send email'] }, status: :unprocessable_entity + return + end + + render jsonapi: verification_request, status: :ok, context: { request: request } + else + render jsonapi_errors: verification_request.errors.presence || { base: ['Failed to save verification request'] }, + status: :unprocessable_entity + end + end + + def verification_request(user, new_email) + token = SecureRandom.hex(24) + expires_at = 2.days.from_now + + EmailChangeRequest.where(user: user, active: true).find_each do |record| + record.update(active: false) + end + + EmailChangeRequest.new( + user: user, + new_email: new_email, + token: token, + expires_at: expires_at + ) + end + + def user_auth_logs + user = User.find_by!(auth_id: current_auth_id) + auth_logs = UserLogsInAuth0.new(user: user).call + objects = auth_logs.map do |log| + OpenStruct.new(log.merge('id' => SecureRandom.uuid)) + end + + render jsonapi: objects, class: { OpenStruct: SerializableUserAuthLog }, status: :ok + end end diff --git a/app/jobs/send_confirm_email_verification_job.rb b/app/jobs/send_confirm_email_verification_job.rb new file mode 100644 index 000000000..424b597b2 --- /dev/null +++ b/app/jobs/send_confirm_email_verification_job.rb @@ -0,0 +1,16 @@ +class SendConfirmEmailVerificationJob < ApplicationJob + queue_as :default + + TEMPLATE_ID = '59cda9d1-9a70-4196-8b50-fba71e410765'.freeze + + def perform(new_email:) + Notify.new.send_email( + template_id: TEMPLATE_ID, + email: new_email, + vars: { + login_url: ENV['FRONTEND_URL'], + email_address: new_email + } + ) + end +end diff --git a/app/jobs/send_email_verification_job.rb b/app/jobs/send_email_verification_job.rb new file mode 100644 index 000000000..3299b4426 --- /dev/null +++ b/app/jobs/send_email_verification_job.rb @@ -0,0 +1,16 @@ +class SendEmailVerificationJob < ApplicationJob + queue_as :default + + TEMPLATE_ID = '26164cdb-915b-4e13-97e9-d4a42367f068'.freeze + + def perform(new_email:, verification_url:) + Notify.new.send_email( + template_id: TEMPLATE_ID, + email: new_email, + vars: { + verify_url: verification_url, + new_email: new_email + } + ) + end +end diff --git a/app/models/api_message.rb b/app/models/api_message.rb new file mode 100644 index 000000000..0f88bb8e5 --- /dev/null +++ b/app/models/api_message.rb @@ -0,0 +1,26 @@ +class ApiMessage + def initialize(attributes = {}) + @attributes = attributes.transform_keys(&:to_sym) + @attributes[:id] ||= SecureRandom.uuid + end + + def id + @attributes[:id] + end + + def as_json(*_args) + @attributes + end + + def method_missing(method, *args, &block) + if @attributes.key?(method) + @attributes[method] + else + super + end + end + + def respond_to_missing?(method, include_private = false) + @attributes.key?(method) || super + end +end diff --git a/app/models/email_change_request.rb b/app/models/email_change_request.rb new file mode 100644 index 000000000..8f404ba0f --- /dev/null +++ b/app/models/email_change_request.rb @@ -0,0 +1,40 @@ +class EmailChangeRequest < ApplicationRecord + has_secure_token :token, length: 64 + belongs_to :user + + validates :new_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } + # validates :token, presence: true, uniqueness: true + validates :expires_at, presence: true + validate :new_email_not_taken + validate :email_not_verified + validate :verifiable, on: :update + + def self.verified_email(user, new_email) + where(user: user, new_email: new_email, active: false) + .where.not(used_at: nil) + .order(used_at: :desc) + .first + end + + def email_not_verified + return if self.class.verified_email(user, new_email).blank? + + errors.add(:new_email, I18n.t('email_verifications.already_verified')) + end + + def verification_url + "#{ENV['FRONTEND_URL']}/email/verification/#{token}" + end + + def verifiable + used_at.nil? && expires_at > Time.current + end + + private + + def new_email_not_taken + return unless User.exists?(email: new_email) + + errors.add(:new_email, 'is already taken') + end +end diff --git a/app/models/user.rb b/app/models/user.rb index ae41a036d..32cba1e76 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,9 @@ require './lib/auth0_api' class User < ApplicationRecord + has_one :email_change_request, lambda { + where(active: true).order(created_at: :desc) + }, class_name: 'EmailChangeRequest', dependent: :destroy, inverse_of: :user has_many :memberships, dependent: :destroy has_many :suppliers, through: :memberships has_many :submissions, through: :suppliers diff --git a/app/serializable/serializable_api_message.rb b/app/serializable/serializable_api_message.rb new file mode 100644 index 000000000..8b8b9da45 --- /dev/null +++ b/app/serializable/serializable_api_message.rb @@ -0,0 +1,9 @@ +class SerializableApiMessage < JSONAPI::Serializable::Resource + type 'users' + + id { @object.id } + + attribute :attributes do + @object.as_json.except(:id) + end +end diff --git a/app/serializable/serializable_email_change_request.rb b/app/serializable/serializable_email_change_request.rb new file mode 100644 index 000000000..b121bccbf --- /dev/null +++ b/app/serializable/serializable_email_change_request.rb @@ -0,0 +1,4 @@ +class SerializableEmailChangeRequest < JSONAPI::Serializable::Resource + type 'email_change_requests' + attributes :new_email, :expires_at, :token, :verification_url +end diff --git a/app/serializable/serializable_user_auth_log.rb b/app/serializable/serializable_user_auth_log.rb new file mode 100644 index 000000000..db96b2a1c --- /dev/null +++ b/app/serializable/serializable_user_auth_log.rb @@ -0,0 +1,13 @@ +# app/serializable/serializable_user_auth_log.rb +class SerializableUserAuthLog < JSONAPI::Serializable::Resource + type 'user_auth_logs' + + attributes :date, :type, :description, :connection, :connection_id, :client_id, :client_name, + :ip, :client_ip, :user_agent, :details, :hostname, :user_id, :user_name, + :auth0_client, :strategy, :strategy_type, :environment_name, :log_id, :tenant_name, + :_id, :isMobile, :location_info + + attribute :event_schema do + @object['$event_schema'] + end +end diff --git a/app/services/notify.rb b/app/services/notify.rb new file mode 100644 index 000000000..c56f0ecb1 --- /dev/null +++ b/app/services/notify.rb @@ -0,0 +1,18 @@ +require 'notifications/client' + +class Notify + def initialize + @client = Notifications::Client.new('rmi_api_notify_api_key-5b735cba-25af-44ca-9494-849b2745365f-e3c04538-df80-4fd1-be19-659b1445e235') + end + + def send_email(template_id:, email:, vars: {}) + @client.send_email( + email_address: email, + template_id: template_id, + personalisation: vars + ) + rescue Notifications::Client::RequestError => e + Rails.logger.error "GOV.UK Notify Error: #{e.message}" + false + end +end diff --git a/app/services/update_user_email.rb b/app/services/update_user_email.rb new file mode 100644 index 000000000..be99c6d49 --- /dev/null +++ b/app/services/update_user_email.rb @@ -0,0 +1,34 @@ +class UpdateUserEmail + include ActiveModel::Validations + + def initialize(user, new_email) + @user = user + @new_email = new_email + end + + def call + User.transaction do + update_user_record + sync_with_auth0 + end + errors.empty? + end + + private + + def update_user_record + return if @user.update(email: @new_email) + + @user.errors.each { |error| errors.add(error.attribute, error.message) } + raise ActiveRecord::Rollback + end + + def sync_with_auth0 + UpdateUserEmailInAuth0.new(user: @user).call + SendConfirmEmailVerificationJob.perform_later(new_email: @new_email) + rescue Auth0::Exception => e + errors.add(:base, "Auth0 update failed: #{e.message}") + Rails.logger.error("Auth0 Error: #{e.message}") + raise ActiveRecord::Rollback + end +end diff --git a/app/services/update_user_email_in_auth0.rb b/app/services/update_user_email_in_auth0.rb new file mode 100644 index 000000000..71779d121 --- /dev/null +++ b/app/services/update_user_email_in_auth0.rb @@ -0,0 +1,17 @@ +class UpdateUserEmailInAuth0 + attr_reader :user + + def initialize(user:) + @user = user + end + + def call + auth0_client.update_user(user.auth_id, email: user.email, verify_email: true, email_verified: false) + end + + private + + def auth0_client + @auth0_client ||= Auth0Api.new.client + end +end diff --git a/app/services/user_logs_in_auth0.rb b/app/services/user_logs_in_auth0.rb new file mode 100644 index 000000000..a2d71b2a5 --- /dev/null +++ b/app/services/user_logs_in_auth0.rb @@ -0,0 +1,24 @@ +require './lib/auth0_api' + +class UserLogsInAuth0 + attr_accessor :user + + def initialize(user:) + self.user = user + end + + def call + query = "(type:\"s\" OR type:\"f\") AND user_id:\"#{user.auth_id}\"" + auth0_client.logs( + q: query, + per_page: 5, + sort: 'date:-1' + ) + end + + private + + def auth0_client + @auth0_client ||= Auth0Api.new.client + end +end diff --git a/app/views/admin/users/edit_email.html.haml b/app/views/admin/users/edit_email.html.haml new file mode 100644 index 000000000..eb0d78f78 --- /dev/null +++ b/app/views/admin/users/edit_email.html.haml @@ -0,0 +1,12 @@ +.govuk-grid-row + .govuk-grid-column-two-thirds + = simple_form_for [:admin, @user], url: update_email_admin_user_path(@user), method: :patch do |form| + %fieldset.govuk-fieldset + %legend.govuk-fieldset__legend.govuk-fieldset__legend--xl + %h1.govuk-fieldset__heading + Update user email + + = render partial: 'shared/error_summary', locals: { entity: @user } if @user.errors.present? + + = form.input :email, hide_optional: true + = form.button :submit, value: 'Update email' diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index fe6a96534..68f40e61f 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -1,3 +1,15 @@ +- if @user.email_change_request&.token.present? + .govuk-notification-banner{"aria-labelledby" => "govuk-notification-banner-title", "data-module" => "govuk-notification-banner", :role => "region"} + .govuk-notification-banner__header + %h2#govuk-notification-banner-title.govuk-notification-banner__title + Important + .govuk-notification-banner__content + %p.govuk-notification-banner__heading + User has a pending action to verify your new email address + = @user.email_change_request.new_email + -# = succeed "." do + -# = link_to 'Resend email', resend_email_verification_path(email: @user.new_email), method: :post, class: 'govuk-notification-banner__link' + .govuk-grid-row .govuk-grid-column-two-thirds = link_to 'Back', admin_users_path, { class: 'govuk-back-link govuk-!-margin-bottom-5', title: 'Back to users' } @@ -20,6 +32,8 @@ = link_to 'Deactivate user', confirm_delete_admin_user_path(@user) %li.govuk-page-actions--action = link_to 'Update user name', edit_admin_user_path(@user) + %li.govuk-page-actions--action + = link_to 'Update user email', edit_email_admin_user_path(@user) - else %li.govuk-page-actions--action = link_to 'Reactivate user', confirm_reactivate_admin_user_path(@user) diff --git a/config/locales/en.yml b/config/locales/en.yml index fa41882a2..c5ec410bc 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,3 @@ ---- en: admin: frameworks: @@ -46,4 +45,12 @@ en: invalid_dependent_field: '"%{value}" is not a valid %{attr} for the given %{parent_field_name} of "%{parent_field_value}". Please refer to the lookups tab in the template.' error_adding_user_to_auth0: 'There was an error adding the user to Auth0. Please try again.' error_updating_user_in_auth0: "There was an error updating this user's name in Auth0. Please try again." + error_updating_user_email_in_auth0: "There was an error updating this user's email in Auth0. Please try again." missing_template_file: 'Missing template file' + error_updating_same_user_email_in_auth0: "Email is unchanged." + success_updating_user_email_in_auth0: "Email updated successfully." + email_verifications: + already_verified: "This email address has already been verified for this user." + verification_sent: "A verification link has been sent: %{url} (expires at %{expires_at})" + invalid_or_expired_token: "Invalid or expired token." + verified_and_updated: "Email address verified and updated." \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 84d0fc374..02e7902c7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -24,9 +24,17 @@ resources :users, only: %i[index] do collection do patch :update_name + patch :update_email + get :user_auth_logs end end + post 'email_verifications/verify_token', to: 'email_verifications#verification', as: :email_verification + post 'email_verifications', to: 'email_verifications#create', as: :generate_email_verification + get 'email_verifications/active_verification', to: 'email_verifications#active', as: :active_email_verification + delete 'email_verifications/cancel_pending_email_change', to: 'email_verifications#cancel_pending_email_change', +as: :cancel_pending_email_change + resources :suppliers, only: %i[index] resources :submissions, only: %i[show create update] do @@ -91,6 +99,8 @@ post :reactivate_user get :confirm_delete get :confirm_reactivate + get :edit_email + patch :update_email end collection do diff --git a/db/migrate/20260321120000_create_email_change_requests.rb b/db/migrate/20260321120000_create_email_change_requests.rb new file mode 100644 index 000000000..4639c0046 --- /dev/null +++ b/db/migrate/20260321120000_create_email_change_requests.rb @@ -0,0 +1,14 @@ +class CreateEmailChangeRequests < ActiveRecord::Migration[6.0] + def change + create_table :email_change_requests do |t| + t.references :user, null: false, foreign_key: true, type: :uuid + t.string :new_email, null: false + t.string :token, null: false + t.datetime :expires_at, null: false + t.datetime :used_at + + t.timestamps + end + add_index :email_change_requests, :token, unique: true + end +end diff --git a/db/migrate/20260322120000_add_active_to_email_change_requests.rb b/db/migrate/20260322120000_add_active_to_email_change_requests.rb new file mode 100644 index 000000000..c03eb3b11 --- /dev/null +++ b/db/migrate/20260322120000_add_active_to_email_change_requests.rb @@ -0,0 +1,6 @@ +class AddActiveToEmailChangeRequests < ActiveRecord::Migration[6.0] + def change + add_column :email_change_requests, :active, :boolean, default: true, null: false + add_index :email_change_requests, :active + end +end diff --git a/db/schema.rb b/db/schema.rb index 9ceb4bca5..3299b9a3a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_10_20_131706) do +ActiveRecord::Schema[8.1].define(version: 2026_03_22_120000) do # These are extensions that must be enabled in order to support this database enable_extension "citext" enable_extension "pg_catalog.plpgsql" @@ -18,23 +18,23 @@ enable_extension "uuid-ossp" create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "name", null: false - t.string "record_type", null: false - t.uuid "record_id", null: false t.uuid "blob_id", null: false t.datetime "created_at", precision: nil, null: false + t.string "name", null: false + t.uuid "record_id", null: false + t.string "record_type", null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end create_table "active_storage_blobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "filename", null: false - t.string "content_type" - t.text "metadata" t.bigint "byte_size", null: false t.string "checksum" + t.string "content_type" t.datetime "created_at", precision: nil, null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" t.string "service_name", null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -47,54 +47,54 @@ create_table "agreement_framework_lots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "agreement_id", null: false - t.uuid "framework_lot_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "framework_lot_id", null: false t.datetime "updated_at", precision: nil, null: false t.index ["agreement_id"], name: "index_agreement_framework_lots_on_agreement_id" t.index ["framework_lot_id"], name: "index_agreement_framework_lots_on_framework_lot_id" end create_table "agreements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.boolean "active", default: true t.uuid "framework_id", null: false t.uuid "supplier_id", null: false - t.boolean "active", default: true t.index ["active"], name: "index_agreements_on_active" t.index ["framework_id"], name: "index_agreements_on_framework_id" t.index ["supplier_id"], name: "index_agreements_on_supplier_id" end create_table "api_keys", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "key", null: false - t.string "description", null: false t.datetime "created_at", null: false + t.string "description", null: false + t.string "key", null: false t.datetime "updated_at", null: false t.index ["key"], name: "index_api_keys_on_key", unique: true end create_table "bulk_user_uploads", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "aasm_state" t.index ["aasm_state"], name: "index_bulk_user_uploads_on_aasm_state" end create_table "customer_effort_scores", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.integer "rating", null: false t.string "comments" t.datetime "created_at", precision: nil + t.integer "rating", null: false t.uuid "user_id" t.index ["user_id"], name: "index_customer_effort_scores_on_user_id" end create_table "customers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.boolean "deleted", default: false t.string "name", null: false t.string "postcode" - t.integer "urn", null: false + t.boolean "published", default: true t.integer "sector", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.boolean "deleted", default: false - t.boolean "published", default: true + t.integer "urn", null: false t.index ["name"], name: "index_customers_on_name" t.index ["postcode"], name: "index_customers_on_postcode" t.index ["sector"], name: "index_customers_on_sector" @@ -107,12 +107,26 @@ t.index ["range_to"], name: "index_data_warehouse_exports_on_range_to" end + create_table "email_change_requests", force: :cascade do |t| + t.boolean "active", default: true, null: false + t.datetime "created_at", null: false + t.datetime "expires_at", precision: nil, null: false + t.string "new_email", null: false + t.string "token", null: false + t.datetime "updated_at", null: false + t.datetime "used_at", precision: nil + t.uuid "user_id", null: false + t.index ["active"], name: "index_email_change_requests_on_active" + t.index ["token"], name: "index_email_change_requests_on_token", unique: true + t.index ["user_id"], name: "index_email_change_requests_on_user_id" + end + create_table "event_store_events", id: :serial, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.text "data", null: false t.uuid "event_id", null: false t.string "event_type", null: false t.text "metadata" - t.text "data", null: false - t.datetime "created_at", precision: nil, null: false t.datetime "valid_at" t.index ["created_at"], name: "index_event_store_events_on_created_at" t.index ["event_id"], name: "index_event_store_events_on_event_id", unique: true @@ -120,39 +134,39 @@ end create_table "event_store_events_in_streams", id: :serial, force: :cascade do |t| - t.string "stream", null: false - t.integer "position" - t.uuid "event_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "event_id", null: false + t.integer "position" + t.string "stream", null: false t.index ["created_at"], name: "index_event_store_events_in_streams_on_created_at" t.index ["stream", "event_id"], name: "index_event_store_events_in_streams_on_stream_and_event_id", unique: true t.index ["stream", "position"], name: "index_event_store_events_in_streams_on_stream_and_position", unique: true end create_table "framework_lots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", precision: nil, null: false + t.string "description" t.uuid "framework_id", null: false t.string "number", null: false - t.string "description" - t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false t.index ["framework_id", "number"], name: "index_framework_lots_on_framework_id_and_number", unique: true t.index ["framework_id"], name: "index_framework_lots_on_framework_id" end create_table "frameworks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state", default: "new", null: false + t.text "definition_source", null: false t.string "name" t.string "short_name", null: false - t.text "definition_source", null: false - t.string "aasm_state", default: "new", null: false t.index ["aasm_state"], name: "index_frameworks_on_aasm_state" t.index ["short_name"], name: "index_frameworks_on_short_name", unique: true end create_table "memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "user_id", null: false - t.uuid "supplier_id", null: false t.datetime "created_at", precision: nil, null: false + t.uuid "supplier_id", null: false t.datetime "updated_at", precision: nil, null: false + t.uuid "user_id", null: false t.index ["supplier_id"], name: "index_memberships_on_supplier_id" t.index ["user_id"], name: "index_memberships_on_user_id" end @@ -161,34 +175,36 @@ t.text "notification_message" t.boolean "published", default: false t.datetime "published_at" + t.datetime "stop_datetime", precision: nil + t.text "summary", null: false t.datetime "unpublished_at" t.string "user" - t.text "summary", null: false t.index ["published"], name: "index_notifications_on_published" + t.index ["stop_datetime"], name: "index_notifications_on_stop_datetime" end create_table "release_notes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.text "header", null: false t.text "body", null: false - t.boolean "published", default: false t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.text "header", null: false + t.boolean "published", default: false t.datetime "published_at" + t.datetime "updated_at", null: false end create_table "submission_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.uuid "submission_file_id" - t.jsonb "source" - t.jsonb "data" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "aasm_state" - t.jsonb "validation_errors" + t.datetime "created_at", precision: nil, null: false + t.integer "customer_urn" + t.jsonb "data" t.string "entry_type" - t.decimal "total_value" t.decimal "management_charge", precision: 18, scale: 4 - t.integer "customer_urn" + t.jsonb "source" + t.uuid "submission_file_id" + t.uuid "submission_id", null: false + t.decimal "total_value" + t.datetime "updated_at", precision: nil, null: false + t.jsonb "validation_errors" t.index ["aasm_state"], name: "index_submission_entries_on_aasm_state" t.index ["entry_type"], name: "index_submission_entries_on_entry_type" t.index ["entry_type"], name: "index_submission_entries_on_invoice_entry_type", where: "((entry_type)::text = 'invoice'::text)" @@ -199,18 +215,18 @@ end create_table "submission_entries_stages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.uuid "submission_file_id" - t.jsonb "source" - t.jsonb "data" - t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "aasm_state" - t.jsonb "validation_errors" + t.datetime "created_at", precision: nil, null: false + t.integer "customer_urn" + t.jsonb "data" t.string "entry_type" - t.decimal "total_value" t.decimal "management_charge", precision: 18, scale: 4 - t.integer "customer_urn" + t.jsonb "source" + t.uuid "submission_file_id" + t.uuid "submission_id", null: false + t.decimal "total_value" + t.datetime "updated_at", precision: nil, null: false + t.jsonb "validation_errors" t.index ["aasm_state"], name: "index_submission_entries_stages_on_aasm_state" t.index ["entry_type"], name: "index_submission_entries_stage_on_invoice_entry_type", where: "((entry_type)::text = 'invoice'::text)" t.index ["entry_type"], name: "index_submission_entries_stages_on_entry_type" @@ -221,36 +237,36 @@ end create_table "submission_files", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.integer "rows" t.datetime "created_at", precision: nil, null: false + t.integer "rows" + t.uuid "submission_id", null: false t.datetime "updated_at", precision: nil, null: false t.index ["submission_id"], name: "index_submission_files_on_submission_id" end create_table "submission_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "submission_id", null: false - t.string "workday_reference" t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.boolean "reversal", default: false, null: false + t.uuid "submission_id", null: false + t.datetime "updated_at", precision: nil, null: false + t.string "workday_reference" t.index ["submission_id"], name: "index_submission_invoices_on_submission_id" end create_table "submissions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.uuid "framework_id", null: false - t.uuid "supplier_id", null: false t.string "aasm_state" - t.uuid "task_id", null: false + t.boolean "cleanup_processed", default: false, null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false - t.string "purchase_order_number" t.uuid "created_by_id" - t.uuid "submitted_by_id" - t.datetime "submitted_at", precision: nil - t.decimal "management_charge_total", precision: 18, scale: 4 + t.uuid "framework_id", null: false t.decimal "invoice_total", precision: 18, scale: 4 - t.boolean "cleanup_processed", default: false, null: false + t.decimal "management_charge_total", precision: 18, scale: 4 + t.string "purchase_order_number" + t.datetime "submitted_at", precision: nil + t.uuid "submitted_by_id" + t.uuid "supplier_id", null: false + t.uuid "task_id", null: false + t.datetime "updated_at", precision: nil, null: false t.index ["aasm_state"], name: "index_submissions_on_aasm_state" t.index ["cleanup_processed"], name: "index_submissions_on_cleanup_processed" t.index ["created_at"], name: "index_submissions_on_created_at", order: :desc @@ -270,15 +286,15 @@ end create_table "tasks", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.string "status", null: false t.datetime "created_at", precision: nil, null: false - t.datetime "updated_at", precision: nil, null: false t.string "description" t.date "due_on" + t.uuid "framework_id" t.integer "period_month" t.integer "period_year" + t.string "status", null: false t.uuid "supplier_id" - t.uuid "framework_id" + t.datetime "updated_at", precision: nil, null: false t.index ["framework_id"], name: "index_tasks_on_framework_id" t.index ["status"], name: "index_tasks_on_status" t.index ["supplier_id"], name: "index_tasks_on_supplier_id" @@ -286,17 +302,17 @@ end create_table "urn_lists", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "aasm_state" t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false - t.string "aasm_state" t.index ["aasm_state"], name: "index_urn_lists_on_aasm_state" end create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "auth_id" - t.string "name" - t.string "email" t.datetime "created_at", precision: nil, null: false + t.string "email" + t.string "name" t.datetime "updated_at", precision: nil, null: false t.index ["auth_id"], name: "index_users_on_auth_id", unique: true end @@ -305,6 +321,7 @@ add_foreign_key "agreement_framework_lots", "agreements" add_foreign_key "agreement_framework_lots", "framework_lots" add_foreign_key "customer_effort_scores", "users" + add_foreign_key "email_change_requests", "users" add_foreign_key "framework_lots", "frameworks" add_foreign_key "memberships", "suppliers" add_foreign_key "submission_entries", "customers", column: "customer_urn", primary_key: "urn" diff --git a/spec/factories/email_change_requests.rb b/spec/factories/email_change_requests.rb new file mode 100644 index 000000000..699b3b59e --- /dev/null +++ b/spec/factories/email_change_requests.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :email_change_request do + association :user + new_email { 'testuser@example.com' } + token { SecureRandom.hex(24) } + expires_at { 2.days.from_now } + active { true } + used_at { nil } + end +end diff --git a/spec/models/email_change_request_spec.rb b/spec/models/email_change_request_spec.rb new file mode 100644 index 000000000..2820a7096 --- /dev/null +++ b/spec/models/email_change_request_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe EmailChangeRequest, type: :model do + let(:user) { FactoryBot.create(:user) } + + describe 'validations' do + it 'is valid with valid attributes' do + request = FactoryBot.build(:email_change_request, user: user) + expect(request).to be_valid + end + + it 'is invalid without a new_email' do + request = FactoryBot.build(:email_change_request, user: user, new_email: nil) + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include("can't be blank") + end + + it 'is invalid with a badly formatted email' do + request = FactoryBot.build(:email_change_request, user: user, new_email: 'not-an-email') + expect(request).not_to be_valid + expect(request.errors[:new_email]).to be_present + end + + it 'is invalid without expires_at' do + request = FactoryBot.build(:email_change_request, user: user, expires_at: nil) + expect(request).not_to be_valid + expect(request.errors[:expires_at]).to include("can't be blank") + end + + it 'is invalid if new_email is already taken by another user' do + existing_user = FactoryBot.create(:user, email: 'taken@example.com') + request = FactoryBot.build(:email_change_request, user: user, new_email: existing_user.email) + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include('is already taken') + end + + it 'is invalid if the email has already been verified for this user' do + FactoryBot.create(:email_change_request, user: user, new_email: 'verified@example.com', active: false, +used_at: Time.current) + request = FactoryBot.build(:email_change_request, user: user, new_email: 'verified@example.com') + expect(request).not_to be_valid + expect(request.errors[:new_email]).to include(I18n.t('email_verifications.already_verified')) + end + end + + describe '.verified_email' do + it 'returns the most recently used verified request' do + older = FactoryBot.build(:email_change_request, user: user, new_email: 'v@example.com', active: false, +used_at: 2.days.ago) + older.save(validate: false) + newer = FactoryBot.build(:email_change_request, user: user, new_email: 'v@example.com', active: false, +used_at: 1.day.ago) + newer.save(validate: false) + result = described_class.verified_email(user, 'v@example.com') + expect(result).to eq(newer) + end + + it 'returns nil when no verified request exists' do + expect(described_class.verified_email(user, 'none@example.com')).to be_nil + end + end + + describe '#verification_url' do + it 'builds the URL from FRONTEND_URL and token' do + request = FactoryBot.create(:email_change_request, user: user) + ClimateControl.modify FRONTEND_URL: 'https://frontend.example.com' do + expect(request.verification_url).to eq("https://frontend.example.com/email/verification/#{request.token}") + end + end + end + + describe '#verifiable' do + it 'returns true when unused and not expired' do + request = FactoryBot.build(:email_change_request, user: user, used_at: nil, expires_at: 2.days.from_now) + expect(request.verifiable).to be true + end + + it 'returns false when already used' do + request = FactoryBot.build(:email_change_request, user: user, used_at: Time.current, expires_at: 2.days.from_now) + expect(request.verifiable).to be false + end + + it 'returns false when expired' do + request = FactoryBot.build(:email_change_request, user: user, used_at: nil, expires_at: 1.day.ago) + expect(request.verifiable).to be false + end + end +end diff --git a/spec/requests/admin/users_controller_spec.rb b/spec/requests/admin/users_controller_spec.rb new file mode 100644 index 000000000..50b580707 --- /dev/null +++ b/spec/requests/admin/users_controller_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe Admin::UsersController, type: :request do + let(:admin) { FactoryBot.create(:user) } + let(:user) { FactoryBot.create(:user) } + + before do + allow_any_instance_of(AdminController).to receive(:ensure_user_signed_in).and_return(true) + allow_any_instance_of(AdminController).to receive(:current_user).and_return({ 'email' => admin.email }) + stub_auth0_token_request + stub_auth0_update_user_request(user) + end + + describe 'GET /admin/users/:id/edit_email' do + it 'renders the edit email form' do + get edit_email_admin_user_path(user) + expect(response).to have_http_status(:ok) + expect(response.body).to include('Update user email') + end + end + + describe 'PATCH /admin/users/:id/update_email' do + it 'updates the user email if changed' do + patch update_email_admin_user_path(user), + params: { user: { email: 'newadmin@example.com' } } + expect(response).to redirect_to(admin_user_path(user)) + follow_redirect! + expect(response.body).to include('Email updated successfully') + end + + it 'does not update if email is unchanged' do + patch update_email_admin_user_path(user), + params: { user: { email: user.email } } + expect(response).to redirect_to(admin_user_path(user)) + follow_redirect! + expect(response.body).to include('Email is unchanged') + end + end +end diff --git a/spec/requests/v1/email_verifications_controller_spec.rb b/spec/requests/v1/email_verifications_controller_spec.rb new file mode 100644 index 000000000..9ae350c94 --- /dev/null +++ b/spec/requests/v1/email_verifications_controller_spec.rb @@ -0,0 +1,70 @@ +require 'rails_helper' + +RSpec.describe V1::EmailVerificationsController, type: :request do + let(:user) { FactoryBot.create(:user) } + let(:auth_headers) { { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') } } + + describe 'POST /v1/email_verifications' do + it 'creates a new email verification request' do + post '/v1/email_verifications', + headers: auth_headers, + params: { _jsonapi: { new_email: 'newuser@example.com' } } + + expect(response).to have_http_status(:ok) + expect(json['data']['type']).to eq('email_change_requests') + expect(json['data']['attributes']['new_email']).to eq('newuser@example.com') + end + + it 'returns errors if already verified' do + FactoryBot.create(:email_change_request, user: user, new_email: 'newuser@example.com', active: false, +used_at: Time.current) + post '/v1/email_verifications', + headers: auth_headers, + params: { _jsonapi: { new_email: 'newuser@example.com' } } + expect(response.status).to eq(422) + expect(json['errors']).not_to be_empty + end + end + + describe 'POST /v1/email_verifications/verify_token' do + it 'verifies a valid token and updates the user email' do + email_change = FactoryBot.create(:email_change_request, user: user, new_email: 'verified@example.com', +active: true, used_at: nil, expires_at: 2.days.from_now) + allow_any_instance_of(UpdateUserEmail).to receive(:call).and_return(true) + post '/v1/email_verifications/verify_token', + params: { _jsonapi: { token: email_change.token } } + expect(response).to have_http_status(:ok) + end + + it 'returns error for invalid or expired token' do + post '/v1/email_verifications/verify_token', + params: { _jsonapi: { token: 'invalidtoken' } } + expect(response.status).to eq(422) + expect(json['errors']).not_to be_empty + end + end + + describe 'GET /v1/email_verifications/active_verification' do + it 'returns the active email change request' do + FactoryBot.create(:email_change_request, user: user, new_email: 'pending@example.com', +active: true, expires_at: 2.days.from_now) + get '/v1/email_verifications/active_verification', headers: auth_headers + expect(response).to have_http_status(:ok) + expect(json['data']['attributes']['new_email']).to eq('pending@example.com') + end + + it 'returns not found if no active request' do + get '/v1/email_verifications/active_verification', headers: auth_headers + expect(response.status).to eq(404) + end + end + + describe 'DELETE /v1/email_verifications/cancel_pending_email_change' do + it 'cancels the pending email change' do + FactoryBot.create(:email_change_request, user: user, new_email: 'cancelme@example.com', active: true, +expires_at: 2.days.from_now) + delete '/v1/email_verifications/cancel_pending_email_change', headers: auth_headers + expect(response).to have_http_status(:no_content) + end + end +end diff --git a/spec/requests/v1/users_spec.rb b/spec/requests/v1/users_spec.rb index b23b46be9..2d52dc755 100644 --- a/spec/requests/v1/users_spec.rb +++ b/spec/requests/v1/users_spec.rb @@ -69,4 +69,60 @@ expect(response.status).to eq 422 end end + + describe 'PATCH /v1/users/update_email' do + it 'returns 422 if email param is missing' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request(user) + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { _jsonapi: {} } + expect(response.status).to eq 422 + expect(json['errors']).not_to be_empty + end + + it 'returns 422 if verification_request fails to save' do + user = FactoryBot.create(:user) + allow_any_instance_of(EmailChangeRequest).to receive(:save).and_return(false) + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { _jsonapi: { email: 'newuser@example.com' } } + expect(response.status).to eq 422 + expect(json['errors']).not_to be_empty + end + it 'updates the email of the current user' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request(user) + + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { + _jsonapi: { + email: 'newuser@example.com' + } + } + + expect(response).to be_successful + expect(json['data']['type']).to eq('email_change_requests') + expect(json['data']['attributes']['new_email']).to eq('newuser@example.com') + end + + it 'returns an error if the Auth0 update fails' do + user = FactoryBot.create(:user) + stub_auth0_token_request + stub_auth0_update_user_request_failure(user) + + patch '/v1/users/update_email', + headers: { 'X-Auth-Id' => JWT.encode(user.auth_id, 'test') }, + params: { + _jsonapi: { + email: 'newuser@example.com' + } + } + + expect(response.status).to eq 200 + end + end end diff --git a/spec/services/update_user_email_in_auth0_spec.rb b/spec/services/update_user_email_in_auth0_spec.rb new file mode 100644 index 000000000..e58fcebed --- /dev/null +++ b/spec/services/update_user_email_in_auth0_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe UpdateUserEmailInAuth0 do + describe '#call' do + let(:user) { create(:user) } + + subject { described_class.new(user: user).call } + + before(:each) do + stub_auth0_token_request + end + + it 'updates the user email in Auth0' do + auth0_update_call = stub_auth0_update_user_request(user) + + subject + + expect(auth0_update_call).to have_been_requested + end + end +end diff --git a/spec/services/update_user_email_spec.rb b/spec/services/update_user_email_spec.rb new file mode 100644 index 000000000..c76418d8b --- /dev/null +++ b/spec/services/update_user_email_spec.rb @@ -0,0 +1,57 @@ +require 'rails_helper' + +RSpec.describe UpdateUserEmail do + let(:user) { FactoryBot.create(:user) } + + before(:each) do + stub_auth0_token_request + end + + describe '#call' do + it 'returns true on success' do + stub_auth0_update_user_request(user) + + result = described_class.new(user, 'newemail@example.com').call + + expect(result).to eq(true) + end + + it 'updates the user email' do + stub_auth0_update_user_request(user) + + described_class.new(user, 'newemail@example.com').call + + expect(user.reload.email).to eq('newemail@example.com') + end + + it 'enqueues a confirmation email job' do + stub_auth0_update_user_request(user) + + expect do + described_class.new(user, 'newemail@example.com').call + end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com') + end + + context 'when Auth0 errors' do + before(:each) do + stub_auth0_update_user_request_failure(user) + end + + it 'returns false' do + result = described_class.new(user, 'newemail@example.com').call + expect(result).to eq(false) + end + + it 'does not update the user email' do + original_email = user.email + described_class.new(user, 'newemail@example.com').call + expect(user.reload.email).to eq(original_email) + end + + it 'logs a failure message' do + expect(Rails.logger).to receive(:error).with(/Auth0 Error/) + described_class.new(user, 'newemail@example.com').call + end + end + end +end diff --git a/spec/services/user_logs_in_auth0_spec.rb b/spec/services/user_logs_in_auth0_spec.rb new file mode 100644 index 000000000..2182e5679 --- /dev/null +++ b/spec/services/user_logs_in_auth0_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe UserLogsInAuth0 do + describe '#call' do + let(:user) { create(:user) } + + subject { described_class.new(user: user).call } + + before(:each) do + stub_auth0_token_request + end + + it 'fetches logs from Auth0' do + expected_query = "(type:\"s\" OR type:\"f\") AND user_id:\"#{user.auth_id}\"" + auth0_logs_call = stub_request(:get, 'https://testdomain/api/v2/logs') + .with(query: hash_including('q' => expected_query)) + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + + subject + + expect(auth0_logs_call).to have_been_requested + end + end +end From 5b2b1c7001909dc7f9683f5121a879e88bc1fca8 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Mon, 13 Apr 2026 20:28:55 +0100 Subject: [PATCH 02/11] Add notifications-ruby-client gem Add the notifications-ruby-client gem to the Gemfile so the application can use the Notifications Ruby client library. The gem is added without a version constraint and is placed at top-level of the Gemfile. --- Gemfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile b/Gemfile index 83a439406..eac616247 100644 --- a/Gemfile +++ b/Gemfile @@ -105,6 +105,7 @@ gem 'net-http', '>= 0.4.0' gem 'sprockets-rails', '>= 3.5.1' gem 'connection_pool', '< 3' +gem 'notifications-ruby-client' group :development, :test do gem 'brakeman', require: false From 2829e8fb6037692164c285c22c18f98c59b39a8f Mon Sep 17 00:00:00 2001 From: mo-zag Date: Mon, 13 Apr 2026 20:29:38 +0100 Subject: [PATCH 03/11] Update Gemfile.lock --- Gemfile.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e651b3209..48657379a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -328,7 +328,6 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2023.0808) mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (6.0.1) prism (~> 1.5) msgpack (1.5.1) @@ -351,9 +350,10 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.5) - nokogiri (1.19.1) - mini_portile2 (~> 2.8.2) + nokogiri (1.19.1-aarch64-linux-musl) racc (~> 1.4) + notifications-ruby-client (6.3.0) + jwt (>= 1.5, < 4) oauth2 (2.0.17) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) @@ -627,7 +627,7 @@ GEM zeitwerk (2.7.4) PLATFORMS - ruby + aarch64-linux-musl DEPENDENCIES aasm @@ -665,6 +665,7 @@ DEPENDENCIES mutex_m (~> 0.3.0) net-http (>= 0.4.0) nokogiri (>= 1.18.9) + notifications-ruby-client omniauth-google-oauth2 (>= 1.2.0) omniauth-rails_csrf_protection (~> 2.0, >= 2.0.0) parslet From 05ca9f0299dd02b3fcc105f6d0747f0627dae6a8 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 20:35:09 +0100 Subject: [PATCH 04/11] Update notify.rb test key removed and will populate to live key in env. --- app/services/notify.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/notify.rb b/app/services/notify.rb index c56f0ecb1..7095571f2 100644 --- a/app/services/notify.rb +++ b/app/services/notify.rb @@ -2,7 +2,7 @@ class Notify def initialize - @client = Notifications::Client.new('rmi_api_notify_api_key-5b735cba-25af-44ca-9494-849b2745365f-e3c04538-df80-4fd1-be19-659b1445e235') + @client = Notifications::Client.new(ENV['GOVUK_NOTIFY_API_KEY']) end def send_email(template_id:, email:, vars: {}) From f52121e519207708b0e277f5fe1a7a1e4736de9c Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 21:13:00 +0100 Subject: [PATCH 05/11] Include person_name in notify jobs and fix ENV key Pass user.name through controllers and services into SendEmailVerificationJob and SendConfirmEmailVerificationJob, add person_name to Notify template vars so emails can include the recipient's name, and update SendConfirmEmailVerificationJob signature. Also correct the Notify client initializer to use ENV['GOV_NOTIFY_API_KEY'] (was GOVUK_NOTIFY_API_KEY). --- app/controllers/v1/email_verifications_controller.rb | 5 +++-- app/controllers/v1/users_controller.rb | 5 +++-- app/jobs/send_confirm_email_verification_job.rb | 5 +++-- app/jobs/send_email_verification_job.rb | 5 +++-- app/services/notify.rb | 2 +- app/services/update_user_email.rb | 2 +- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/controllers/v1/email_verifications_controller.rb b/app/controllers/v1/email_verifications_controller.rb index 01aa12360..0d0499645 100644 --- a/app/controllers/v1/email_verifications_controller.rb +++ b/app/controllers/v1/email_verifications_controller.rb @@ -11,8 +11,9 @@ def create if verification_request.save SendEmailVerificationJob.perform_later( - email: new_email, - verification_url: verification_request.verification_url + new_email: new_email, + verification_url: verification_request.verification_url, + person_name: user.name ) render jsonapi: verification_request, status: :ok, context: { request: request } diff --git a/app/controllers/v1/users_controller.rb b/app/controllers/v1/users_controller.rb index 62f4ff45a..3ef853c73 100644 --- a/app/controllers/v1/users_controller.rb +++ b/app/controllers/v1/users_controller.rb @@ -31,8 +31,9 @@ def update_email if verification_request.save notify_result = SendEmailVerificationJob.perform_later( - email: new_email, - verification_url: verification_request.verification_url + new_email: new_email, + verification_url: verification_request.verification_url, + person_name: user.name ) if notify_result == false diff --git a/app/jobs/send_confirm_email_verification_job.rb b/app/jobs/send_confirm_email_verification_job.rb index 424b597b2..bfd87094b 100644 --- a/app/jobs/send_confirm_email_verification_job.rb +++ b/app/jobs/send_confirm_email_verification_job.rb @@ -3,13 +3,14 @@ class SendConfirmEmailVerificationJob < ApplicationJob TEMPLATE_ID = '59cda9d1-9a70-4196-8b50-fba71e410765'.freeze - def perform(new_email:) + def perform(new_email:, person_name: nil) Notify.new.send_email( template_id: TEMPLATE_ID, email: new_email, vars: { login_url: ENV['FRONTEND_URL'], - email_address: new_email + email_address: new_email, + person_name: person_name } ) end diff --git a/app/jobs/send_email_verification_job.rb b/app/jobs/send_email_verification_job.rb index 3299b4426..77c87a173 100644 --- a/app/jobs/send_email_verification_job.rb +++ b/app/jobs/send_email_verification_job.rb @@ -3,13 +3,14 @@ class SendEmailVerificationJob < ApplicationJob TEMPLATE_ID = '26164cdb-915b-4e13-97e9-d4a42367f068'.freeze - def perform(new_email:, verification_url:) + def perform(new_email:, verification_url:, person_name: nil) Notify.new.send_email( template_id: TEMPLATE_ID, email: new_email, vars: { verify_url: verification_url, - new_email: new_email + new_email: new_email, + person_name: person_name } ) end diff --git a/app/services/notify.rb b/app/services/notify.rb index 7095571f2..919df32fc 100644 --- a/app/services/notify.rb +++ b/app/services/notify.rb @@ -2,7 +2,7 @@ class Notify def initialize - @client = Notifications::Client.new(ENV['GOVUK_NOTIFY_API_KEY']) + @client = Notifications::Client.new(ENV['GOV_NOTIFY_API_KEY']) end def send_email(template_id:, email:, vars: {}) diff --git a/app/services/update_user_email.rb b/app/services/update_user_email.rb index be99c6d49..ab52c77cb 100644 --- a/app/services/update_user_email.rb +++ b/app/services/update_user_email.rb @@ -25,7 +25,7 @@ def update_user_record def sync_with_auth0 UpdateUserEmailInAuth0.new(user: @user).call - SendConfirmEmailVerificationJob.perform_later(new_email: @new_email) + SendConfirmEmailVerificationJob.perform_later(new_email: @new_email, person_name: @user.name) rescue Auth0::Exception => e errors.add(:base, "Auth0 update failed: #{e.message}") Rails.logger.error("Auth0 Error: #{e.message}") From 046a39af760d2899f65a544c333bf667a496cd81 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 21:16:25 +0100 Subject: [PATCH 06/11] Update email_change_request.rb --- app/models/email_change_request.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/email_change_request.rb b/app/models/email_change_request.rb index 8f404ba0f..48f79b38d 100644 --- a/app/models/email_change_request.rb +++ b/app/models/email_change_request.rb @@ -3,7 +3,6 @@ class EmailChangeRequest < ApplicationRecord belongs_to :user validates :new_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - # validates :token, presence: true, uniqueness: true validates :expires_at, presence: true validate :new_email_not_taken validate :email_not_verified From 7ecb1843a1c1b7be5446ffb83e809ced0682fb53 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 21:26:52 +0100 Subject: [PATCH 07/11] Update Gemfile.lock --- Gemfile.lock | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index dfd4021fd..815694cb8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -352,10 +352,12 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.5) - notifications-ruby-client (6.3.0) nokogiri (1.19.2) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.19.2-x86_64-linux-gnu) + racc (~> 1.4) + notifications-ruby-client (6.3.0) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) @@ -630,6 +632,8 @@ GEM PLATFORMS aarch64-linux-musl + ruby + x86_64-linux DEPENDENCIES aasm @@ -668,7 +672,7 @@ DEPENDENCIES net-http (>= 0.4.0) nokogiri (>= 1.18.9) notifications-ruby-client - omniauth-google-oauth2 (>= 1.2.0) + omniauth-google-oauth2 (>= 1.2.2) omniauth-rails_csrf_protection (~> 2.0, >= 2.0.0) parslet pg (>= 0.18, < 2.0) From a9e159f4f2a5c3f6cada90adfbcdee5e1bd41fb4 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 21:29:22 +0100 Subject: [PATCH 08/11] Update Gemfile.lock --- Gemfile.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile.lock b/Gemfile.lock index 815694cb8..62d38be18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -358,6 +358,7 @@ GEM nokogiri (1.19.2-x86_64-linux-gnu) racc (~> 1.4) notifications-ruby-client (6.3.0) + jwt (>= 1.5, < 4) oauth2 (2.0.18) faraday (>= 0.17.3, < 4.0) jwt (>= 1.0, < 4.0) From 20448d387c7abb623581906c9710326dd562a807 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 16 Apr 2026 01:12:17 +0100 Subject: [PATCH 09/11] Update update_user_email_spec.rb --- spec/services/update_user_email_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/services/update_user_email_spec.rb b/spec/services/update_user_email_spec.rb index c76418d8b..3136638ac 100644 --- a/spec/services/update_user_email_spec.rb +++ b/spec/services/update_user_email_spec.rb @@ -29,7 +29,7 @@ expect do described_class.new(user, 'newemail@example.com').call - end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com') + end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com', person_name: user.name) end context 'when Auth0 errors' do From 3d95c409ec64995147d7d57b4127d364921af13e Mon Sep 17 00:00:00 2001 From: mo-zag Date: Thu, 16 Apr 2026 01:30:50 +0100 Subject: [PATCH 10/11] Update update_user_email_spec.rb --- spec/services/update_user_email_spec.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/services/update_user_email_spec.rb b/spec/services/update_user_email_spec.rb index 3136638ac..31adc4b24 100644 --- a/spec/services/update_user_email_spec.rb +++ b/spec/services/update_user_email_spec.rb @@ -29,7 +29,8 @@ expect do described_class.new(user, 'newemail@example.com').call - end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com', person_name: user.name) + end.to have_enqueued_job(SendConfirmEmailVerificationJob).with(new_email: 'newemail@example.com', + person_name: user.name) end context 'when Auth0 errors' do From 0f15964040ecb1a3e69c8c8056167a57596f10e9 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Tue, 28 Apr 2026 20:19:28 +0100 Subject: [PATCH 11/11] Fix email verification flags for Auth0 update Swap the verify_email and email_verified flags when updating a user's email in Auth0. Previously the code set verify_email: true and email_verified: false (which would trigger a verification email and leave the account unverified); now it sets verify_email: false and email_verified: true to mark the new email as verified without sending a verification email. --- app/services/update_user_email_in_auth0.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/update_user_email_in_auth0.rb b/app/services/update_user_email_in_auth0.rb index 71779d121..65a1ac4bb 100644 --- a/app/services/update_user_email_in_auth0.rb +++ b/app/services/update_user_email_in_auth0.rb @@ -6,7 +6,7 @@ def initialize(user:) end def call - auth0_client.update_user(user.auth_id, email: user.email, verify_email: true, email_verified: false) + auth0_client.update_user(user.auth_id, email: user.email, verify_email: false, email_verified: true) end private