From 666212eba3043c3f1c458345009c8958699eaa9a Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 25 Mar 2026 13:41:11 +0000 Subject: [PATCH 1/5] Add email verification and email update flow Introduce email verification prompt and add an email change flow. - SessionsController: add verify_email action and email_verified? helper (with previous enforcement commented out); add sessions/verify_email view to prompt users to verify their email. - UserDetailsController: add edit_email and update_email actions with server-side validation (presence, confirmation match, format), API::User.update_email call, and connection error handling. Returns appropriate flash messages and status codes. - Views: add user_details/edit_email.haml form and a link in user_details/show to change email. - API: register update_email custom endpoint on API::User. - Routes: add routes for edit_email/update_email on user_detail resource and verify_email route for sessions. Provides UX for unverified emails and a complete edit/update flow with validation and error handling. --- app/controllers/sessions_controller.rb | 23 ++++++++++++++ app/controllers/user_details_controller.rb | 33 +++++++++++++++++++++ app/models/api/user.rb | 1 + app/views/sessions/verify_email.html.erb | 2 ++ app/views/user_details/edit_email.html.haml | 26 ++++++++++++++++ app/views/user_details/show.html.haml | 1 + config/routes.rb | 6 +++- 7 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/views/sessions/verify_email.html.erb create mode 100644 app/views/user_details/edit_email.html.haml diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 9115eccc..acd5783c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,12 +3,35 @@ class SessionsController < ApplicationController def create session[:auth_id] = auth_id + + # if !email_verified? + # reset_session + # redirect_to verify_email_path + # return + # end Auditor.new.user_signed_in(user_id: auth_id) redirect_to '/tasks' end + def verify_email + render :verify_email + end + + + private + + def email_verified? + if auth_hash.info.respond_to?(:email_verified) + !!auth_hash.info.email_verified + elsif auth_hash.extra && auth_hash.extra.raw_info && auth_hash.extra.raw_info.respond_to?(:email_verified) + !!auth_hash.extra.raw_info.email_verified + else + false + end + end + def destroy Auditor.new.user_signed_out(user_id: session[:auth_id]) diff --git a/app/controllers/user_details_controller.rb b/app/controllers/user_details_controller.rb index 149276ab..c84a7907 100644 --- a/app/controllers/user_details_controller.rb +++ b/app/controllers/user_details_controller.rb @@ -20,4 +20,37 @@ def update flash.now[:alert] = 'There was a problem connecting to the user service. Please try again later.' render :edit, status: :service_unavailable end + def edit_email; end + + def update_email + new_email = params[:email] + email_confirmation = params[:email_confirmation] + + error_message = email_update_validation(new_email, email_confirmation) + if error_message + flash.now[:alert] = error_message + return render :edit_email, status: :unprocessable_entity + end + + api_response = API::User.update_email(email: new_email) + + if api_response.any? + redirect_to user_detail_path, notice: 'Your email address has been updated successfully.' + else + flash.now[:alert] = 'There was a problem updating your email address. Please try again.' + render :edit_email, status: :unprocessable_entity + end + rescue JSONAPI::Consumer::Errors::ConnectionError + flash.now[:alert] = 'There was a problem connecting to the user service. Please try again later.' + render :edit_email, status: :service_unavailable + end + + private + + def email_update_validation(email, confirmation) + return 'Please enter and confirm your email address.' if email.blank? || confirmation.blank? + return 'Email addresses do not match. Please try again.' unless email == confirmation + return 'Please enter a valid email address.' unless email =~ URI::MailTo::EMAIL_REGEXP + nil + end end diff --git a/app/models/api/user.rb b/app/models/api/user.rb index 86c4bdc3..63069988 100644 --- a/app/models/api/user.rb +++ b/app/models/api/user.rb @@ -1,5 +1,6 @@ module API class User < Base custom_endpoint :update_name, on: :collection, request_method: :patch + custom_endpoint :update_email, on: :collection, request_method: :patch end end diff --git a/app/views/sessions/verify_email.html.erb b/app/views/sessions/verify_email.html.erb new file mode 100644 index 00000000..a818a3c5 --- /dev/null +++ b/app/views/sessions/verify_email.html.erb @@ -0,0 +1,2 @@ +

Verify your email address

+

Your email address is not verified. Please check your inbox for a verification email and follow the instructions to verify your account before continuing.

\ No newline at end of file diff --git a/app/views/user_details/edit_email.html.haml b/app/views/user_details/edit_email.html.haml new file mode 100644 index 00000000..a1ebfc1b --- /dev/null +++ b/app/views/user_details/edit_email.html.haml @@ -0,0 +1,26 @@ +- page_title 'Change your email address' + +.govuk-grid-row + .govuk-grid-column-two-thirds + %h1.govuk-heading-xl Change your email address + + - if flash[:alert].present? + .govuk-error-summary{role: "alert", tabindex: "-1"} + .govuk-error-summary__title + %h2.govuk-error-summary__title There is a problem + .govuk-error-summary__body + %ul.govuk-list.govuk-error-summary__list + %li= flash[:alert] + + = form_with url: update_email_user_detail_path, method: :patch, local: true do |form| + .govuk-form-group + = form.label :email, 'Email address', class: 'govuk-label' + = form.email_field :email, class: 'govuk-input' + + .govuk-form-group + = form.label :email_confirmation, 'Confirm email address', class: 'govuk-label' + = form.email_field :email_confirmation, class: 'govuk-input' + + %p{class: 'govuk-!-margin-top-7'} + = form.submit 'Update email', class: 'govuk-button' + = link_to 'Cancel', user_detail_path, class: 'govuk-link govuk-!-margin-left-3 govuk-!-font-size-19' diff --git a/app/views/user_details/show.html.haml b/app/views/user_details/show.html.haml index 5cd743cb..6b194b5f 100644 --- a/app/views/user_details/show.html.haml +++ b/app/views/user_details/show.html.haml @@ -19,6 +19,7 @@ %td.govuk-table__cell Email %td.govuk-table__cell= current_user.email %td.govuk-table__cell + = link_to 'Change email', edit_email_user_detail_path, class: 'govuk-link' %tr.govuk-table__row %td.govuk-table__cell User since %td.govuk-table__cell= user_created_date(current_user).to_fs(:default) diff --git a/config/routes.rb b/config/routes.rb index c405b709..0f29dd59 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,11 +41,15 @@ resources :release_notes, only: %i[index] - resource :user_detail, only: %i[show edit update] + resource :user_detail, only: %i[show edit update] do + get :edit_email + patch :update_email + end match '/auth/:provider/callback', to: 'sessions#create', via: %i[get post] match '/auth/failure', to: 'errors#auth_failure', via: %i[get post] get '/sign_out', to: 'sessions#destroy', as: :sign_out + get '/verify_email', to: 'sessions#verify_email', as: :verify_email get '/style_guide', to: 'styleguide#index' get '/support', to: 'support#index' get '/support/frameworks', to: 'support#frameworks' From c316fb759c76fad3ed930a17d0d147896cf5ea21 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 03:35:37 +0100 Subject: [PATCH 2/5] Add email verification and user auth logs Introduce email verification flow and user authentication logs. - Add EmailVerificationsController with show, resend_email and cancel_pending_email_change actions. - Add API::EmailVerification model with helper methods (verify_email, email_verification_pending?, delete_pending_email_change) and custom endpoints. - Add routes for viewing, resending and cancelling email verification. - Add view for email verification confirmation and show notification banner in user_details when a pending email change exists. - Surface user auth logs on the profile page and add API::User custom endpoint for user_auth_logs. - Add internal server error JSON builder. - Update specs and test helpers to mock the new endpoints. --- .../email_verifications_controller.rb | 34 ++++++++++++++++++ app/controllers/user_details_controller.rb | 7 +++- app/models/api/email_verification.rb | 29 +++++++++++++++ app/models/api/user.rb | 1 + app/views/email_verifications/show.html.haml | 6 ++++ .../internal_server_error.json.jbuilder | 2 ++ app/views/user_details/show.html.haml | 36 +++++++++++++++++++ config/routes.rb | 4 +++ spec/features/user_can_view_profile_spec.rb | 2 ++ .../users_can_sign_in_and_out_spec.rb | 6 ++++ spec/requests/user_details_spec.rb | 2 ++ spec/support/api_helpers.rb | 8 +++++ 12 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 app/controllers/email_verifications_controller.rb create mode 100644 app/models/api/email_verification.rb create mode 100644 app/views/email_verifications/show.html.haml create mode 100644 app/views/errors/internal_server_error.json.jbuilder diff --git a/app/controllers/email_verifications_controller.rb b/app/controllers/email_verifications_controller.rb new file mode 100644 index 00000000..c6d13680 --- /dev/null +++ b/app/controllers/email_verifications_controller.rb @@ -0,0 +1,34 @@ +class EmailVerificationsController < ApplicationController + skip_before_action :ensure_user_signed_in, only: [:show] + + def show + @verification = API::EmailVerification.verify_email(tokened: params[:token]) + + @message = if @verification + 'You’ve successfully verified your email. You can now continue to your account.' + else + 'Verification link is invalid or has expired.' + end + rescue StandardError => e + Rails.logger.warn("Email verification failed: \\#{e.class} - \\#{e.message}") + @message = 'Verification link is invalid or has expired.' + end + + def resend_email + if API::User.update_email(email: params[:email]).any? + redirect_to user_detail_path, notice: 'A new verification email has been sent to your new email address.' + end + rescue JSONAPI::Consumer::Errors::ConnectionError + redirect_to user_detail_path, alert: 'There was a problem connecting to the email service. Please try again later.' + end + + def cancel_pending_email_change + if API::EmailVerification.delete_pending_email_change + redirect_to user_detail_path, notice: 'Your pending email change has been cancelled.' + else + redirect_to user_detail_path, alert: 'There was a problem cancelling your pending email change. Please try again.' + end + rescue JSONAPI::Consumer::Errors::ConnectionError + redirect_to user_detail_path, alert: 'There was a problem connecting to the email service. Please try again later.' + end +end diff --git a/app/controllers/user_details_controller.rb b/app/controllers/user_details_controller.rb index c84a7907..dc7ee1fe 100644 --- a/app/controllers/user_details_controller.rb +++ b/app/controllers/user_details_controller.rb @@ -1,6 +1,8 @@ class UserDetailsController < ApplicationController def show @suppliers = API::Supplier.all + @email_verification = API::EmailVerification.email_verification_pending? + @user_logs = API::User.user_auth_logs end def edit; end @@ -20,6 +22,7 @@ def update flash.now[:alert] = 'There was a problem connecting to the user service. Please try again later.' render :edit, status: :service_unavailable end + def edit_email; end def update_email @@ -35,7 +38,8 @@ def update_email api_response = API::User.update_email(email: new_email) if api_response.any? - redirect_to user_detail_path, notice: 'Your email address has been updated successfully.' + redirect_to user_detail_path, + notice: 'You must verify your new email address. Please check your inbox for a verification email.' else flash.now[:alert] = 'There was a problem updating your email address. Please try again.' render :edit_email, status: :unprocessable_entity @@ -51,6 +55,7 @@ def email_update_validation(email, confirmation) return 'Please enter and confirm your email address.' if email.blank? || confirmation.blank? return 'Email addresses do not match. Please try again.' unless email == confirmation return 'Please enter a valid email address.' unless email =~ URI::MailTo::EMAIL_REGEXP + nil end end diff --git a/app/models/api/email_verification.rb b/app/models/api/email_verification.rb new file mode 100644 index 00000000..d17926ac --- /dev/null +++ b/app/models/api/email_verification.rb @@ -0,0 +1,29 @@ +# app/models/api/email_verification.rb +module API + class EmailVerification < Base + custom_endpoint :verify_token, on: :collection, request_method: :post + custom_endpoint :active_verification, on: :collection, request_method: :get + custom_endpoint :cancel_pending_email_change, on: :collection, request_method: :delete + + def self.verify_email(tokened:) + response = verify_token({ token: tokened }) + response&.any? + rescue JSONAPI::Consumer::Errors::NotFound, JSONAPI::Consumer::Errors::ConnectionError + false + end + + def self.email_verification_pending? + response = active_verification + response&.first + rescue JSONAPI::Consumer::Errors::NotFound, JSONAPI::Consumer::Errors::ConnectionError + nil + end + + def self.delete_pending_email_change + cancel_pending_email_change + true + rescue JSONAPI::Consumer::Errors::NotFound, JSONAPI::Consumer::Errors::ConnectionError + false + end + end +end diff --git a/app/models/api/user.rb b/app/models/api/user.rb index 63069988..984def89 100644 --- a/app/models/api/user.rb +++ b/app/models/api/user.rb @@ -2,5 +2,6 @@ module API class User < Base custom_endpoint :update_name, on: :collection, request_method: :patch custom_endpoint :update_email, on: :collection, request_method: :patch + custom_endpoint :user_auth_logs, on: :collection, request_method: :get end end diff --git a/app/views/email_verifications/show.html.haml b/app/views/email_verifications/show.html.haml new file mode 100644 index 00000000..69079d3b --- /dev/null +++ b/app/views/email_verifications/show.html.haml @@ -0,0 +1,6 @@ +- page_title 'Email Verification' +%div{ class: "govuk-panel govuk-panel--confirmation govuk-!-margin-top-6 govuk-!-margin-bottom-6" } + %h1.govuk-panel__title + Email Verification + %div.govuk-panel__body + = @message diff --git a/app/views/errors/internal_server_error.json.jbuilder b/app/views/errors/internal_server_error.json.jbuilder new file mode 100644 index 00000000..d7b531fa --- /dev/null +++ b/app/views/errors/internal_server_error.json.jbuilder @@ -0,0 +1,2 @@ +json.error 'Internal Server Error' +json.message 'Sorry, something went wrong. Please try again later.' diff --git a/app/views/user_details/show.html.haml b/app/views/user_details/show.html.haml index 6b194b5f..dbb1b334 100644 --- a/app/views/user_details/show.html.haml +++ b/app/views/user_details/show.html.haml @@ -1,5 +1,19 @@ - page_title 'User profile' +- if @email_verification&.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 + Verify your new email address + = @email_verification.new_email + = succeed "." do + = link_to 'Resend email', resend_email_verification_path(email: @email_verification.new_email), method: :post, class: 'govuk-notification-banner__link' + .govuk-button-group + = button_to 'Cancel', cancel_pending_email_change_path, method: :post, data: { confirm: 'Are you sure you want to cancel your pending email change?' }, class: 'govuk-button govuk-button--secondary' + .govuk-grid-row .govuk-grid-column-full %h1.govuk-heading-xl @@ -24,6 +38,28 @@ %td.govuk-table__cell User since %td.govuk-table__cell= user_created_date(current_user).to_fs(:default) %td.govuk-table__cell + - if @user_logs.present? + %tr.govuk-table__row + %td.govuk-table__cell User log(s) + %td.govuk-table__cell + - @user_logs.each_with_index do |log, i| + %ul.govuk-list.govuk-list + %li + %strong Date: + = Time.parse(log.date).utc.strftime("%d %B %Y, %l:%M %p") + %li + %strong IP: + = log.ip + %li + %strong User Agent: + = log.user_agent + %li + %strong Device: + = log.isMobile ? "Mobile" : "Desktop" + - unless i == @user_logs.size - 1 + %hr.govuk-section-break.govuk-section-break--visible + + %td.govuk-table__cell %tr.govuk-table__row %td.govuk-table__cell Supplier(s) %td.govuk-table__cell diff --git a/config/routes.rb b/config/routes.rb index 0f29dd59..cbedcae9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -57,4 +57,8 @@ get '/cookie-policy', to: 'home#cookie_policy' get '/accessibility', to: 'accessibility#index' get '/check', to: 'check#index' + get '/email/verification/:token', to: 'email_verifications#show', as: :email_verification + post '/email/verification/resend', to: 'email_verifications#resend_email', as: :resend_email_verification + post '/email/verification/cancel_pending_email_change', to: 'email_verifications#cancel_pending_email_change', + as: :cancel_pending_email_change end diff --git a/spec/features/user_can_view_profile_spec.rb b/spec/features/user_can_view_profile_spec.rb index 227ca154..50b7d0b7 100644 --- a/spec/features/user_can_view_profile_spec.rb +++ b/spec/features/user_can_view_profile_spec.rb @@ -6,6 +6,8 @@ mock_user_with_multiple_suppliers_endpoint! mock_notifications_endpoint! mock_suppliers_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! visit '/' click_button 'sign-in' diff --git a/spec/features/users_can_sign_in_and_out_spec.rb b/spec/features/users_can_sign_in_and_out_spec.rb index 5def0bcc..9e669906 100644 --- a/spec/features/users_can_sign_in_and_out_spec.rb +++ b/spec/features/users_can_sign_in_and_out_spec.rb @@ -7,6 +7,8 @@ mock_incomplete_tasks_endpoint! mock_notifications_endpoint! mock_user_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! visit '/tasks' @@ -38,6 +40,8 @@ mock_unstarted_tasks_endpoint! mock_incomplete_tasks_endpoint! mock_user_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! click_on 'sign-in' @@ -51,6 +55,8 @@ mock_incomplete_tasks_endpoint! mock_notifications_endpoint! mock_user_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! visit '/' click_on 'sign-in' diff --git a/spec/requests/user_details_spec.rb b/spec/requests/user_details_spec.rb index 280a44bc..d2997b4a 100644 --- a/spec/requests/user_details_spec.rb +++ b/spec/requests/user_details_spec.rb @@ -5,6 +5,8 @@ mock_notifications_endpoint! mock_user_with_multiple_suppliers_endpoint! mock_suppliers_endpoint! + mock_email_verification_pending_endpoint! + mock_user_auth_logs_endpoint! end describe 'visiting the user profile page' do diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index 2af830c9..3213d532 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -486,6 +486,14 @@ def mock_update_user_name_endpoint_connection_error! .to_raise(Faraday::ConnectionFailed.new('Connection failed')) end + def mock_email_verification_pending_endpoint!(result = nil) + allow(API::EmailVerification).to receive(:email_verification_pending?).and_return(result) + end + + def mock_user_auth_logs_endpoint!(logs = []) + allow(API::User).to receive(:user_auth_logs).and_return(logs) + end + private def json_headers From c0b8e755caf560c013bc8a9d72f852b44359b9e0 Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 03:59:42 +0100 Subject: [PATCH 3/5] Update sessions_controller.rb --- app/controllers/sessions_controller.rb | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index acd5783c..8afec9ea 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -3,35 +3,12 @@ class SessionsController < ApplicationController def create session[:auth_id] = auth_id - - # if !email_verified? - # reset_session - # redirect_to verify_email_path - # return - # end Auditor.new.user_signed_in(user_id: auth_id) redirect_to '/tasks' end - def verify_email - render :verify_email - end - - - private - - def email_verified? - if auth_hash.info.respond_to?(:email_verified) - !!auth_hash.info.email_verified - elsif auth_hash.extra && auth_hash.extra.raw_info && auth_hash.extra.raw_info.respond_to?(:email_verified) - !!auth_hash.extra.raw_info.email_verified - else - false - end - end - def destroy Auditor.new.user_signed_out(user_id: session[:auth_id]) @@ -50,4 +27,4 @@ def auth_id def auth_hash request.env['omniauth.auth'] end -end +end \ No newline at end of file From 58a3465c1b1277ed9d710a6acafd225d2427c3fc Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 15 Apr 2026 04:04:59 +0100 Subject: [PATCH 4/5] Update sessions_controller.rb --- app/controllers/sessions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8afec9ea..9115eccc 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -27,4 +27,4 @@ def auth_id def auth_hash request.env['omniauth.auth'] end -end \ No newline at end of file +end From 28b5dc1db512a19960f453aa0a1cf0a6b1b9842d Mon Sep 17 00:00:00 2001 From: mo-zag Date: Wed, 20 May 2026 01:58:47 +0100 Subject: [PATCH 5/5] Update show.html.haml --- app/views/user_details/show.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/user_details/show.html.haml b/app/views/user_details/show.html.haml index dbb1b334..081cd35e 100644 --- a/app/views/user_details/show.html.haml +++ b/app/views/user_details/show.html.haml @@ -40,13 +40,13 @@ %td.govuk-table__cell - if @user_logs.present? %tr.govuk-table__row - %td.govuk-table__cell User log(s) + %td.govuk-table__cell Previous logins %td.govuk-table__cell - @user_logs.each_with_index do |log, i| %ul.govuk-list.govuk-list %li %strong Date: - = Time.parse(log.date).utc.strftime("%d %B %Y, %l:%M %p") + = Time.parse(log.date).utc.strftime("%d %B %Y %H:%M UTC") %li %strong IP: = log.ip