diff --git a/app/components/icon_list_item_component.html.erb b/app/components/icon_list_item_component.html.erb index 128a16fccb8..baa4d53d8c7 100644 --- a/app/components/icon_list_item_component.html.erb +++ b/app/components/icon_list_item_component.html.erb @@ -2,5 +2,5 @@ <%= content_tag(:div, class: icon_css_class) do %> <%= render IconComponent.new(icon: icon) %> <% end %> -
<%= content %>
+
<%= content %>
<% end %> diff --git a/app/controllers/concerns/saml_idp_auth_concern.rb b/app/controllers/concerns/saml_idp_auth_concern.rb index cc87d04087a..e209ad0037f 100644 --- a/app/controllers/concerns/saml_idp_auth_concern.rb +++ b/app/controllers/concerns/saml_idp_auth_concern.rb @@ -136,9 +136,18 @@ def link_identity_from_session_data link_identity( ial: resolved_authn_context_int_ial, rails_session_id: session.id, + email_address_id: email_address_id, ) end + def email_address_id + return nil unless IdentityConfig.store.feature_select_email_to_share_enabled + return user_session[:selected_email_id] if user_session[:selected_email_id].present? + identity = current_user.identities.find_by(service_provider: sp_session['issuer']) + email_id = identity&.email_address_id + return email_id if email_id.is_a? Integer + end + def identity_needs_verification? resolved_authn_context_result.identity_proofing? && current_user.identity_not_verified? end diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index faa27028cba..f792dd55ac6 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -84,9 +84,17 @@ def link_identity_to_service_provider current_user: current_user, ial: resolved_authn_context_int_ial, rails_session_id: session.id, + email_address_id: email_address_id, ) end + def email_address_id + return nil unless IdentityConfig.store.feature_select_email_to_share_enabled + return user_session[:selected_email_id] if user_session[:selected_email_id].present? + identity = current_user.identities.find_by(service_provider: sp_session['issuer']) + identity&.email_address_id + end + def ial_context IalContext.new( ial: resolved_authn_context_int_ial, diff --git a/app/controllers/sign_up/completions_controller.rb b/app/controllers/sign_up/completions_controller.rb index 1107fa56309..007e1609c95 100644 --- a/app/controllers/sign_up/completions_controller.rb +++ b/app/controllers/sign_up/completions_controller.rb @@ -21,6 +21,10 @@ def update track_completion_event('agency-page') update_verified_attributes send_in_person_completion_survey + if user_session[:selected_email_id].nil? + user_session[:selected_email_id] = EmailContext.new(current_user). + last_sign_in_email_address.id + end if decider.go_back_to_mobile_app? sign_user_out_and_instruct_to_go_back_to_mobile_app else @@ -49,6 +53,7 @@ def completions_presenter requested_attributes: decorated_sp_session.requested_attributes.map(&:to_sym), ial2_requested: ial2_requested?, completion_context: needs_completion_screen_reason, + selected_email_id: user_session[:selected_email_id], ) end diff --git a/app/controllers/sign_up/select_email_controller.rb b/app/controllers/sign_up/select_email_controller.rb new file mode 100644 index 00000000000..2c6b52a2382 --- /dev/null +++ b/app/controllers/sign_up/select_email_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SignUp + class SelectEmailController < ApplicationController + before_action :confirm_two_factor_authenticated + before_action :verify_needs_completions_screen + + def show + @sp_name = current_sp.friendly_name || sp.agency&.name + @user_emails = user_emails + @last_sign_in_email_address = last_email + @select_email_form = build_select_email_form + end + + def create + @select_email_form = build_select_email_form + + result = @select_email_form.submit(form_params) + if result.success? + user_session[:selected_email_id] = form_params[:selected_email_id] + redirect_to sign_up_completed_path + else + flash[:error] = result.first_error_message + redirect_to sign_up_select_email_path + end + end + + def user_emails + @user_emails = current_user.confirmed_email_addresses + end + + private + + def build_select_email_form + SelectEmailForm.new(current_user) + end + + def form_params + params.fetch(:select_email_form, {}).permit(:selected_email_id) + end + + def last_email + if user_session[:selected_email_id] + user_emails.find(user_session[:selected_email_id]).email + else + EmailContext.new(current_user).last_sign_in_email_address.email + end + end + + def verify_needs_completions_screen + redirect_to account_url unless needs_completion_screen_reason + end + end +end diff --git a/app/forms/openid_connect_authorize_form.rb b/app/forms/openid_connect_authorize_form.rb index a70dcf8d09d..bbe816167a7 100644 --- a/app/forms/openid_connect_authorize_form.rb +++ b/app/forms/openid_connect_authorize_form.rb @@ -94,7 +94,8 @@ def service_provider def link_identity_to_service_provider( current_user:, ial:, - rails_session_id: + rails_session_id:, + email_address_id: ) identity_linker = IdentityLinker.new(current_user, service_provider) @identity = identity_linker.link_identity( @@ -106,6 +107,7 @@ def link_identity_to_service_provider( requested_aal_value: requested_aal_value, scope: scope.join(' '), code_challenge: code_challenge, + email_address_id: email_address_id, ) end diff --git a/app/forms/select_email_form.rb b/app/forms/select_email_form.rb new file mode 100644 index 00000000000..165d5e0f331 --- /dev/null +++ b/app/forms/select_email_form.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class SelectEmailForm + include ActiveModel::Model + include ActionView::Helpers::TranslationHelper + + attr_reader :user, :selected_email_id + + validate :validate_owns_selected_email + + def initialize(user) + @user = user + end + + def submit(params) + @selected_email_id = params[:selected_email_id] + + success = valid? + FormResponse.new(success:, errors:) + end + + private + + def validate_owns_selected_email + return if user.confirmed_email_addresses.exists?(id: selected_email_id) + + errors.add :email, I18n.t( + 'email_address.not_found', + ), type: :selected_email_id + end +end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index b4d1f28fc3c..67c58d9f687 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -3,6 +3,8 @@ class EmailAddress < ApplicationRecord include EncryptableAttribute + before_destroy :reset_linked_identities + encrypted_attribute_without_setter(name: :email) belongs_to :user, inverse_of: :email_addresses @@ -10,6 +12,8 @@ class EmailAddress < ApplicationRecord validates :email_fingerprint, presence: true # rubocop:disable Rails/HasManyOrHasOneDependent has_one :suspended_email + + has_many :identities, class_name: 'ServiceProviderIdentity' # rubocop:enable Rails/HasManyOrHasOneDependent scope :confirmed, -> { where('confirmed_at IS NOT NULL') } @@ -90,4 +94,17 @@ def create_fingerprints(email) [Pii::Fingerprinter.fingerprint(email), *Pii::Fingerprinter.previous_fingerprints(email)] end end + + private + + # Remove email id from all user identities + # when the email is destroyed. + def reset_linked_identities + # rubocop:disable Rails/SkipsModelValidations + ServiceProviderIdentity.where( + user_id: user_id, + email_address_id: id, + ).update_all(email_address_id: nil) + # rubocop:enable Rails/SkipsModelValidations + end end diff --git a/app/models/service_provider_identity.rb b/app/models/service_provider_identity.rb index dd17e94a0f3..5030aea024c 100644 --- a/app/models/service_provider_identity.rb +++ b/app/models/service_provider_identity.rb @@ -19,6 +19,8 @@ class ServiceProviderIdentity < ApplicationRecord # rubocop:enable Rails/InverseOf has_one :agency, through: :service_provider_record + belongs_to :email_address + scope :not_deleted, -> { where(deleted_at: nil) } CONSENT_EXPIRATION = 1.year.freeze diff --git a/app/presenters/completions_presenter.rb b/app/presenters/completions_presenter.rb index 8e5b831624b..55f8cadb77a 100644 --- a/app/presenters/completions_presenter.rb +++ b/app/presenters/completions_presenter.rb @@ -4,7 +4,8 @@ class CompletionsPresenter include ActionView::Helpers::TranslationHelper include ActionView::Helpers::TagHelper - attr_reader :current_user, :current_sp, :decrypted_pii, :requested_attributes, :completion_context + attr_reader :current_user, :current_sp, :decrypted_pii, :requested_attributes, + :completion_context, :selected_email_id SORTED_IAL2_ATTRIBUTE_MAPPING = [ [[:email], :email], @@ -33,7 +34,8 @@ def initialize( decrypted_pii:, requested_attributes:, ial2_requested:, - completion_context: + completion_context:, + selected_email_id: ) @current_user = current_user @current_sp = current_sp @@ -41,6 +43,7 @@ def initialize( @requested_attributes = requested_attributes @ial2_requested = ial2_requested @completion_context = completion_context + @selected_email_id = selected_email_id end def ial2_requested? @@ -102,6 +105,10 @@ def pii end end + def multiple_emails? + current_user.confirmed_email_addresses.many? + end + private def first_time_signing_in? @@ -112,6 +119,7 @@ def displayable_pii @displayable_pii ||= DisplayablePiiFormatter.new( current_user: current_user, pii: decrypted_pii, + selected_email_id: @selected_email_id, ).format end diff --git a/app/presenters/openid_connect_user_info_presenter.rb b/app/presenters/openid_connect_user_info_presenter.rb index 1fd4f925e22..bc87e20231b 100644 --- a/app/presenters/openid_connect_user_info_presenter.rb +++ b/app/presenters/openid_connect_user_info_presenter.rb @@ -54,7 +54,7 @@ def uuid_from_sp_identity(identity) end def email_from_sp_identity - email_context.last_sign_in_email_address.email + identity.email_address&.email || email_context.last_sign_in_email_address.email end def all_emails_from_sp_identity(identity) diff --git a/app/services/attribute_asserter.rb b/app/services/attribute_asserter.rb index 0707bb0cca3..53c0e8b2cd0 100644 --- a/app/services/attribute_asserter.rb +++ b/app/services/attribute_asserter.rb @@ -200,7 +200,10 @@ def attribute_getter_function_ascii(attr) def add_email(attrs) attrs[:email] = { - getter: ->(principal) { EmailContext.new(principal).last_sign_in_email_address.email }, + getter: ->(principal) { + last_email_from_sp(principal) || + EmailContext.new(principal).last_sign_in_email_address.email + }, name_format: 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', name_id_format: Saml::XML::Namespaces::Formats::NameId::EMAIL_ADDRESS, } @@ -214,6 +217,13 @@ def add_all_emails(attrs) } end + def last_email_from_sp(principal) + return nil unless IdentityConfig.store.feature_select_email_to_share_enabled + identity = principal.active_identity_for(service_provider) + email_id = identity&.email_address_id + principal.confirmed_email_addresses.find_by(id: email_id)&.email if email_id + end + def bundle @bundle ||= ( authn_request_bundle || service_provider.metadata[:attribute_bundle] || [] diff --git a/app/services/displayable_pii_formatter.rb b/app/services/displayable_pii_formatter.rb index a9c61ad1580..1bc6399fd0b 100644 --- a/app/services/displayable_pii_formatter.rb +++ b/app/services/displayable_pii_formatter.rb @@ -8,10 +8,12 @@ class DisplayablePiiFormatter attr_reader :current_user attr_reader :pii + attr_reader :selected_email_id - def initialize(current_user:, pii:) + def initialize(current_user:, pii:, selected_email_id:) @current_user = current_user @pii = pii + @selected_email_id = selected_email_id end # @return [FormattedPii] @@ -36,7 +38,11 @@ def format private def email - EmailContext.new(current_user).last_sign_in_email_address.email + if @selected_email_id + current_user.confirmed_email_addresses.find(@selected_email_id).email + else + EmailContext.new(current_user).last_sign_in_email_address.email + end end def all_emails diff --git a/app/services/identity_linker.rb b/app/services/identity_linker.rb index b34f76077f7..8f69f422560 100644 --- a/app/services/identity_linker.rb +++ b/app/services/identity_linker.rb @@ -25,7 +25,8 @@ def link_identity( scope: nil, verified_attributes: nil, last_consented_at: nil, - clear_deleted_at: nil + clear_deleted_at: nil, + email_address_id: nil ) return unless user && service_provider.present? @@ -43,6 +44,7 @@ def link_identity( rails_session_id: rails_session_id, scope: scope, verified_attributes: combined_verified_attributes(verified_attributes), + email_address_id: email_address_id, ).tap do |hash| hash[:last_consented_at] = last_consented_at if last_consented_at hash[:deleted_at] = nil if clear_deleted_at diff --git a/app/views/sign_up/completions/show.html.erb b/app/views/sign_up/completions/show.html.erb index 6dbe1572268..e019e17a33d 100644 --- a/app/views/sign_up/completions/show.html.erb +++ b/app/views/sign_up/completions/show.html.erb @@ -42,6 +42,17 @@ last_number: attribute_value[-1], ), ) %> + <% elsif attribute_key == :email && IdentityConfig.store.feature_select_email_to_share_enabled %> +
+ <%= attribute_value.to_s %> +

+ <% if @presenter.multiple_emails? %> + <%= link_to t('help_text.requested_attributes.change_email_link'), sign_up_select_email_path %> + <% else %> + <%= link_to t('account.index.email_add'), add_email_path %> + <% end %> +

+
<% else %> <%= attribute_value.to_s %> <% end %> diff --git a/app/views/sign_up/select_email/show.html.erb b/app/views/sign_up/select_email/show.html.erb new file mode 100644 index 00000000000..c89ce9a7676 --- /dev/null +++ b/app/views/sign_up/select_email/show.html.erb @@ -0,0 +1,50 @@ +<% self.title = t('titles.select_email') %> + +<%= render StatusPageComponent.new(status: :info, icon: :question) do |c| %> + <% c.with_header { t('titles.select_email') } %> +

+ <%= I18n.t('help_text.select_preferred_email', sp: @sp_name, app_name: APP_NAME) %> +

+ + <%= simple_form_for('', url: sign_up_select_email_path) do |f| %> +
+
+
+
+ <% @user_emails.each do |email, index| %> +
+ <%= radio_button_tag( + 'select_email_form[selected_email_id]', + email.id, + email.email == @last_sign_in_email_address, + class: 'usa-radio__input usa-radio__input--bordered', + ) %> + <%= label_tag( + "select_email_form_selected_email_id_#{email.id}", + class: 'usa-radio__label width-full', + ) do %> + <%= email.email %> + <% end %> +
+ <% end %> +
+
+
+
+
+ <%= f.submit t('help_text.requested_attributes.change_email_link'), class: 'margin-top-4' %> + <% end %> + + <%= render ButtonComponent.new( + url: add_email_path, + outline: true, + big: true, + wide: true, + class: 'margin-top-2', + ).with_content(t('account.index.email_add')) %> + + <%= render PageFooterComponent.new do %> + <%= link_to t('forms.buttons.back'), sign_up_completed_path %> + <% end %> + +<% end %> \ No newline at end of file diff --git a/config/application.yml.default b/config/application.yml.default index f2993cb3feb..48113c3296f 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -118,6 +118,7 @@ enable_usps_verification: true event_disavowal_expiration_hours: 240 feature_idv_force_gpo_verification_enabled: false feature_idv_hybrid_flow_enabled: true +feature_select_email_to_share_enabled: true geo_data_file_path: 'geo_data/GeoLite2-City.mmdb' get_usps_proofing_results_job_cron: '0/30 * * * *' get_usps_proofing_results_job_reprocess_delay_minutes: 5 @@ -469,6 +470,7 @@ production: email_registrations_per_ip_track_only_mode: true enable_test_routes: false enable_usps_verification: false + feature_select_email_to_share_enabled: false hmac_fingerprinter_key: hmac_fingerprinter_key_queue: '[]' idv_sp_required: true diff --git a/config/locales/en.yml b/config/locales/en.yml index 264a782eb80..c2c6b1c610b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -692,6 +692,7 @@ doc_auth.tips.review_issues_id_text1: Did you use a dark background? doc_auth.tips.review_issues_id_text2: Did you take the photo on a flat surface? doc_auth.tips.review_issues_id_text3: Is the flash on your camera off? doc_auth.tips.review_issues_id_text4: Are all details sharp and clearly visible? +email_address.not_found: 'Email not found' email_addresses.add.duplicate: This email address is already registered to your account. email_addresses.add.limit: You’ve added the maximum number of email addresses. email_addresses.delete.bullet1: You won’t be able to sign in to %{app_name} (or any of the government applications linked to your account) using this email address @@ -958,6 +959,7 @@ headings.webauthn_setup.new: Insert your security key help_text.requested_attributes.address: Address help_text.requested_attributes.all_emails: Email addresses on your account help_text.requested_attributes.birthdate: Date of birth +help_text.requested_attributes.change_email_link: Change help_text.requested_attributes.consent_reminder_html: You must consent each year to share your information with %{sp_html}. help_text.requested_attributes.email: Email address help_text.requested_attributes.full_name: Full name @@ -968,6 +970,7 @@ help_text.requested_attributes.social_security_number: Social Security number help_text.requested_attributes.verified_at: Updated on help_text.requested_attributes.x509_issuer: PIV/CAC Issuer help_text.requested_attributes.x509_subject: PIV/CAC Identity +help_text.select_preferred_email: You may change which email you share with %{sp} since you have multiple emails associated with your %{app_name} account. i18n.language: Language i18n.locale.en: English i18n.locale.es: Español @@ -1604,6 +1607,7 @@ titles.reactivate_account: Reactivate your account titles.registrations.new: Create your account titles.revoke_consent: Revoke Consent titles.rules_of_use: Rules of Use +titles.select_email: Select your preferred email titles.sign_up.completion_consent_expired_ial1: It’s been a year since you gave us consent to share your information titles.sign_up.completion_consent_expired_ial2: It’s been a year since you gave us consent to share your verified identity titles.sign_up.completion_first_sign_in: Continue to %{sp} diff --git a/config/locales/es.yml b/config/locales/es.yml index 2b93d049f09..89247b5e8dd 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -703,6 +703,7 @@ doc_auth.tips.review_issues_id_text1: '¿Usó un fondo de color oscuro?' doc_auth.tips.review_issues_id_text2: '¿Tomó la foto en una superficie plana?' doc_auth.tips.review_issues_id_text3: '¿Está apagado el flash de su cámara?' doc_auth.tips.review_issues_id_text4: '¿Todos los detalles se ven con precisión y claridad?' +email_address.not_found: El correo electrónico no encontrado email_addresses.add.duplicate: Esta dirección de correo electrónico ya está registrada en su cuenta. email_addresses.add.limit: Agregó el número máximo de direcciones de correo electrónico. email_addresses.delete.bullet1: Si usa esta dirección de correo electrónico, no podrá iniciar sesión en %{app_name} (ni en ninguna de las aplicaciones gubernamentales vinculadas a su cuenta). @@ -969,6 +970,7 @@ headings.webauthn_setup.new: Inserte su clave de seguridad help_text.requested_attributes.address: Dirección help_text.requested_attributes.all_emails: Direcciones de correo electrónico en su cuenta help_text.requested_attributes.birthdate: Fecha de nacimiento +help_text.requested_attributes.change_email_link: Cambiar help_text.requested_attributes.consent_reminder_html: Debe dar su consentimiento cada año para divulgar su información a %{sp_html}. help_text.requested_attributes.email: Dirección de correo electrónico help_text.requested_attributes.full_name: Nombre completo @@ -979,6 +981,7 @@ help_text.requested_attributes.social_security_number: Número de Seguro Social help_text.requested_attributes.verified_at: Actualizado en help_text.requested_attributes.x509_issuer: Emisor de la tarjeta PIV o CAC help_text.requested_attributes.x509_subject: Identidad de la tarjeta PIV o CAC +help_text.select_preferred_email: Puede cambiar el correo electrónico que comparte con %{sp} ya que tiene varios correos electrónicos asociados a su cuenta de %{app_name}. i18n.language: Idioma i18n.locale.en: English i18n.locale.es: Español @@ -1616,6 +1619,7 @@ titles.reactivate_account: Reactive su cuenta titles.registrations.new: Cree su cuenta titles.revoke_consent: Revocar consentimiento titles.rules_of_use: Reglas de uso +titles.select_email: Seleccione el correo electrónico que prefiera titles.sign_up.completion_consent_expired_ial1: Hace un año que nos dio su consentimiento para divulgar su información titles.sign_up.completion_consent_expired_ial2: Hace un año que nos dio su consentimiento para divulgar su identidad verificada titles.sign_up.completion_first_sign_in: Continuar con %{sp} diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 38ebe6c74df..f8ed669bb52 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -692,6 +692,7 @@ doc_auth.tips.review_issues_id_text1: Avez-vous utilisé un arrière-plan de cou doc_auth.tips.review_issues_id_text2: Avez-vous pris la photo sur une surface plane ? doc_auth.tips.review_issues_id_text3: Le flash de votre appareil photo est-il éteint ? doc_auth.tips.review_issues_id_text4: Tous les détails sont-ils nets et clairement visibles ? +email_address.not_found: Email non trouvé email_addresses.add.duplicate: Cette adresse e-mail est déjà enregistrée sur votre compte. email_addresses.add.limit: Vous avez ajouté le nombre maximum d’adresses e-mail. email_addresses.delete.bullet1: Vous ne pourrez pas vous connecter à %{app_name} (ni à aucune des applications de l’administration associées à votre compte) à l’aide de cette adresse e-mail @@ -958,6 +959,7 @@ headings.webauthn_setup.new: Insérer votre clé de sécurité help_text.requested_attributes.address: Adresse help_text.requested_attributes.all_emails: Adresses e-mail sur votre compte help_text.requested_attributes.birthdate: Date de naissance +help_text.requested_attributes.change_email_link: Modifier help_text.requested_attributes.consent_reminder_html: Vous devez consentir chaque année au partage de vos informations avec %{sp_html}. help_text.requested_attributes.email: Adresse e-mail help_text.requested_attributes.full_name: Nom complet @@ -968,6 +970,7 @@ help_text.requested_attributes.social_security_number: Numéro de sécurité soc help_text.requested_attributes.verified_at: Mis à jour le help_text.requested_attributes.x509_issuer: Émetteur PIV/CAC help_text.requested_attributes.x509_subject: Identité PIV/CAC +help_text.select_preferred_email: Vous pouvez modifier l’adresse e-mail que vous partagez avec %{sp} car vous possédez plusieurs adresses e-mail associées à votre compte %{app_name}. i18n.language: Langue i18n.locale.en: English i18n.locale.es: Español @@ -1604,6 +1607,7 @@ titles.reactivate_account: Réactiver votre compte titles.registrations.new: Créer votre compte titles.revoke_consent: Révoquer le consentement titles.rules_of_use: Règles d’utilisation +titles.select_email: Sélectionner l’adresse e-mail de votre choix titles.sign_up.completion_consent_expired_ial1: Cela fait un an que vous nous avez donné votre consentement pour partager vos informations titles.sign_up.completion_consent_expired_ial2: Cela fait un an que vous nous avez donné votre consentement pour partager votre identité vérifiée titles.sign_up.completion_first_sign_in: Continuer vers %{sp} diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 4e23eb1bea3..d308cb8c188 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -703,6 +703,7 @@ doc_auth.tips.review_issues_id_text1: 你是否使用了暗色背景? doc_auth.tips.review_issues_id_text2: 是否在平坦平面上拍的照? doc_auth.tips.review_issues_id_text3: 你相机的闪光灯是否关闭? doc_auth.tips.review_issues_id_text4: 是否所有细节都清晰可见? +email_address.not_found: 未找到电子邮件 email_addresses.add.duplicate: 该电邮地址已注册到你的账户。 email_addresses.add.limit: 你添加的电邮地址数目已达最多。 email_addresses.delete.bullet1: 使用该电邮地址你无法登录进入 %{app_name} (或任何其他与你账户关联的政府应用程序)。 @@ -971,6 +972,7 @@ headings.webauthn_setup.new: 插入您的安全密钥 help_text.requested_attributes.address: 地址 help_text.requested_attributes.all_emails: 你账户上的电邮地址 help_text.requested_attributes.birthdate: 生日 +help_text.requested_attributes.change_email_link: 更改 help_text.requested_attributes.consent_reminder_html: 你每年都必须授权同意与 %{sp_html} 分享信息。 help_text.requested_attributes.email: 电邮地址 help_text.requested_attributes.full_name: 姓名 @@ -981,6 +983,7 @@ help_text.requested_attributes.social_security_number: 社会保障号码 help_text.requested_attributes.verified_at: 更新是在 help_text.requested_attributes.x509_issuer: PIV/CAC 发放方 help_text.requested_attributes.x509_subject: PIV/CAC 身份 +help_text.select_preferred_email: 因为你有多个电邮与 %{app_name} 账户相关,你可以更改与我们 %{sp} 构分享哪个。 i18n.language: 语言 i18n.locale.en: English i18n.locale.es: Español @@ -1617,6 +1620,7 @@ titles.reactivate_account: 重新激活你账户 titles.registrations.new: 设立账户 titles.revoke_consent: 撤销同意 titles.rules_of_use: 使用规则 +titles.select_email: 选择你比较愿意分享的电邮 titles.sign_up.completion_consent_expired_ial1: 从你上次授权我们分享你的信息已经一年了。 titles.sign_up.completion_consent_expired_ial2: 从你上次授权我们分享你验证过的身份已经一年了。 titles.sign_up.completion_first_sign_in: 继续到 %{sp} diff --git a/config/routes.rb b/config/routes.rb index 30863b3e145..eb35a338ee7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -304,6 +304,8 @@ get '/sign_up/enter_email' => 'sign_up/registrations#new', as: :sign_up_email post '/sign_up/enter_email' => 'sign_up/registrations#create', as: :sign_up_register get '/sign_up/enter_password' => 'sign_up/passwords#new' + get '/sign_up/select_email' => 'sign_up/select_email#show' + post '/sign_up/select_email' => 'sign_up/select_email#create' get '/sign_up/verify_email' => 'sign_up/emails#show', as: :sign_up_verify_email get '/sign_up/completed' => 'sign_up/completions#show', as: :sign_up_completed post '/sign_up/completed' => 'sign_up/completions#update' diff --git a/lib/identity_config.rb b/lib/identity_config.rb index d4af025ba85..a4b83521aa2 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -138,6 +138,7 @@ def self.store config.add(:event_disavowal_expiration_hours, type: :integer) config.add(:feature_idv_force_gpo_verification_enabled, type: :boolean) config.add(:feature_idv_hybrid_flow_enabled, type: :boolean) + config.add(:feature_select_email_to_share_enabled, type: :boolean) config.add(:geo_data_file_path, type: :string) config.add(:get_usps_proofing_results_job_cron, type: :string) config.add(:get_usps_proofing_results_job_reprocess_delay_minutes, type: :integer) diff --git a/spec/controllers/sign_up/select_email_controller_spec.rb b/spec/controllers/sign_up/select_email_controller_spec.rb new file mode 100644 index 00000000000..898c402091d --- /dev/null +++ b/spec/controllers/sign_up/select_email_controller_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe SignUp::SelectEmailController do + describe 'before_actions' do + it 'requires the user be logged in and authenticated' do + expect(subject).to have_actions( + :before, + :confirm_two_factor_authenticated, + ) + end + + it 'requires the user be in the completions flow' do + expect(subject).to have_actions( + :before, + :verify_needs_completions_screen, + ) + end + end + + describe '#create' do + let(:email) { 'michael.motorist@email.com' } + let(:email2) { 'michael.motorist2@email.com' } + let(:email3) { 'david.motorist@email.com' } + let(:user) { create(:user) } + + before do + user.email_addresses = [] + create(:email_address, user:, email: email) + create(:email_address, user:, email: email2) + end + + it 'updates selected email address' do + post :create, params: { selected_email_id: email2 } + + expect(user.email_addresses.last.email). + to include('michael.motorist2@email.com') + end + + context 'with a corrupted email selected_email_id form' do + render_views + it 'rejects email not belonging to the user' do + stub_sign_in(user) + allow(controller).to receive(:needs_completion_screen_reason).and_return(true) + post :create, params: { selected_email_id: email3 } + + expect(user.email_addresses.last.email). + to include('michael.motorist2@email.com') + + expect(response).to redirect_to(sign_up_select_email_path) + end + end + end +end diff --git a/spec/features/multiple_emails/sp_sign_in_spec.rb b/spec/features/multiple_emails/sp_sign_in_spec.rb index dbae13eb376..be6d75b55b6 100644 --- a/spec/features/multiple_emails/sp_sign_in_spec.rb +++ b/spec/features/multiple_emails/sp_sign_in_spec.rb @@ -17,15 +17,62 @@ fill_in_code_with_last_phone_otp click_submit_default click_agree_and_continue if current_path == sign_up_completed_path - decoded_id_token = fetch_oidc_id_token_info - expect(decoded_id_token[:email]).to eq(email) + expect(decoded_id_token[:email]).to eq(emails.first) expect(decoded_id_token[:all_emails]).to be_nil Capybara.reset_session! end end + scenario 'signing in with OIDC and selecting an alternative email address at first sign in' do + user = create(:user, :fully_registered, :with_multiple_emails) + emails = user.reload.email_addresses.map(&:email) + + visit_idp_from_oidc_sp(scope: 'openid email') + signin(emails.first, user.password) + fill_in_code_with_last_phone_otp + click_submit_default + click_link(t('help_text.requested_attributes.change_email_link')) + + choose emails.second + + click_button(t('help_text.requested_attributes.change_email_link')) + + expect(current_path).to eq(sign_up_completed_path) + click_agree_and_continue + decoded_id_token = fetch_oidc_id_token_info + expect(decoded_id_token[:email]).to eq(emails.second) + end + + scenario 'signing in with OIDC after deleting email linked to identity' do + user = create(:user, :fully_registered) + email1 = create(:email_address, user:, email: 'email1@example.com') + email2 = create(:email_address, user:, email: 'email2@example.com') + + # Link identity with email + visit_idp_from_oidc_sp(scope: 'openid email') + signin(email1.email, user.password) + fill_in_code_with_last_phone_otp + click_submit_default + click_link(t('help_text.requested_attributes.change_email_link')) + choose email2.email + click_button(t('help_text.requested_attributes.change_email_link')) + expect(current_path).to eq(sign_up_completed_path) + click_agree_and_continue + click_submit_default + + # Delete email from account + visit manage_email_confirm_delete_url(id: email2.id) + click_button t('forms.email.buttons.delete') + + # Sign in again to partner application + visit_idp_from_oidc_sp(scope: 'openid email') + + decoded_id_token = fetch_oidc_id_token_info + expect(decoded_id_token[:email]).to eq(email1.email) + end + scenario 'signing in with SAML sends the email address used to sign in' do user = create(:user, :fully_registered, :with_multiple_emails) emails = user.reload.email_addresses.map(&:email) @@ -42,12 +89,63 @@ xmldoc = SamlResponseDoc.new('feature', 'response_assertion') email_from_saml_response = xmldoc.attribute_value_for('email') - - expect(email_from_saml_response).to eq(email) + expect(email_from_saml_response).to eq(emails.first) Capybara.reset_session! end end + + scenario 'signing in with SAML and selecting an alternative email address at first sign in' do + user = create(:user, :fully_registered, :with_multiple_emails) + emails = user.reload.email_addresses.map(&:email) + + visit authn_request + signin(emails.first, user.password) + fill_in_code_with_last_phone_otp + click_submit_default_twice + + click_link(t('help_text.requested_attributes.change_email_link')) + choose emails.second + click_button(t('help_text.requested_attributes.change_email_link')) + + expect(current_path).to eq(sign_up_completed_path) + + click_agree_and_continue + click_submit_default + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + email_from_saml_response = xmldoc.attribute_value_for('email') + expect(email_from_saml_response).to eq(emails.second) + end + + scenario 'signing in with SAML after deleting email linked to identity' do + user = create(:user, :fully_registered) + email1 = create(:email_address, user:, email: 'email1@example.com') + email2 = create(:email_address, user:, email: 'email2@example.com') + + # Link identity with email + visit authn_request + signin(email1.email, user.password) + fill_in_code_with_last_phone_otp + click_submit_default_twice + click_link(t('help_text.requested_attributes.change_email_link')) + choose email2.email + click_button(t('help_text.requested_attributes.change_email_link')) + expect(current_path).to eq(sign_up_completed_path) + click_agree_and_continue + click_submit_default + + # Delete email from account + visit manage_email_confirm_delete_url(id: email2.id) + click_button t('forms.email.buttons.delete') + + # Sign in again to partner application + visit authn_request + + xmldoc = SamlResponseDoc.new('feature', 'response_assertion') + email_from_saml_response = xmldoc.attribute_value_for('email') + expect(email_from_saml_response).to eq(email1.email) + end end context 'with the all_emails scope' do diff --git a/spec/forms/openid_connect_authorize_form_spec.rb b/spec/forms/openid_connect_authorize_form_spec.rb index 568c2553535..580983f27d7 100644 --- a/spec/forms/openid_connect_authorize_form_spec.rb +++ b/spec/forms/openid_connect_authorize_form_spec.rb @@ -661,6 +661,7 @@ current_user: user, ial: 1, rails_session_id: rails_session_id, + email_address_id: 4, ) identity = user.identities.where(service_provider: client_id).first @@ -684,6 +685,7 @@ current_user: user, ial: 1, rails_session_id: rails_session_id, + email_address_id: 4, ) end diff --git a/spec/forms/select_email_form_spec.rb b/spec/forms/select_email_form_spec.rb new file mode 100644 index 00000000000..23a17bdbba5 --- /dev/null +++ b/spec/forms/select_email_form_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe SelectEmailForm do + let(:user) { create(:user, :fully_registered, :with_multiple_emails) } + describe '#submit' do + it 'returns the email successfully' do + form = SelectEmailForm.new(user) + response = form.submit(selected_email_id: user.email_addresses.last.id) + + expect(response.success?).to eq(true) + end + + it 'returns an error when submitting an invalid email' do + form = SelectEmailForm.new(user) + response = form.submit(selected_email_id: nil) + + expect(response.success?).to eq(false) + end + + context 'with an unconfirmed email address added' do + before do + create( + :email_address, + email: 'michael.business@business.com', + user: user, + confirmed_at: nil, + confirmation_sent_at: 1.month.ago, + ) + end + + it 'returns an error' do + form = SelectEmailForm.new(user) + response = form.submit(selected_email_id: user.email_addresses.last.id) + + expect(response.success?).to eq(false) + end + end + + context 'with another user\'s email' do + let(:user2) { create(:user, :fully_registered, :with_multiple_emails) } + before do + create( + :email_address, + email: 'michael.business@business.com', + user: user2, + confirmed_at: nil, + confirmation_sent_at: 1.month.ago, + ) + @email2 = user2.email_addresses.last.id + end + + it 'returns an error' do + form = SelectEmailForm.new(user) + response = form.submit(selected_email_id: @email2) + + expect(response.success?).to eq(false) + end + end + end +end diff --git a/spec/models/email_address_spec.rb b/spec/models/email_address_spec.rb index 50adfa6256f..eb9b3f9f0d1 100644 --- a/spec/models/email_address_spec.rb +++ b/spec/models/email_address_spec.rb @@ -143,6 +143,24 @@ end end + describe 'destruction' do + let(:user) { create(:user) } + let(:email) { 'jd@example.com' } + let(:email_address) { create(:email_address, email: email) } + + it 'removes associated identity email address id' do + user.identities << ServiceProviderIdentity.create( + service_provider: 'http://localhost:3000', + last_authenticated_at: Time.zone.now, + email_address_id: user.email_addresses.last.id, + ) + + user.email_addresses.last.destroy + + expect(user.identities.last.email_address_id).to be(nil) + end + end + describe '#fed_email?' do subject(:result) { email_address.fed_email? } let!(:federal_email_domain) { create(:federal_email_domain, name: 'gsa.gov') } diff --git a/spec/presenters/completions_presenter_spec.rb b/spec/presenters/completions_presenter_spec.rb index 07c32b8deb1..132dd2fe5e8 100644 --- a/spec/presenters/completions_presenter_spec.rb +++ b/spec/presenters/completions_presenter_spec.rb @@ -15,6 +15,7 @@ end let(:current_user) { create(:user, :fully_registered, identities: identities) } let(:current_sp) { create(:service_provider, friendly_name: 'Friendly service provider') } + let(:selected_email_id) { current_user.email_addresses.first.id } let(:decrypted_pii) do Pii::Attributes.new( first_name: 'Testy', @@ -41,12 +42,13 @@ subject(:presenter) do described_class.new( - current_user: current_user, - current_sp: current_sp, - decrypted_pii: decrypted_pii, - requested_attributes: requested_attributes, - ial2_requested: ial2_requested, - completion_context: completion_context, + current_user:, + current_sp:, + decrypted_pii:, + requested_attributes:, + ial2_requested:, + completion_context:, + selected_email_id:, ) end diff --git a/spec/presenters/openid_connect_user_info_presenter_spec.rb b/spec/presenters/openid_connect_user_info_presenter_spec.rb index e22176e0329..8aa2bfecce4 100644 --- a/spec/presenters/openid_connect_user_info_presenter_spec.rb +++ b/spec/presenters/openid_connect_user_info_presenter_spec.rb @@ -368,5 +368,47 @@ end end end + + context 'with a deleted email' do + let(:identity) do + build( + :service_provider_identity, + rails_session_id: rails_session_id, + user: create(:user, :fully_registered, :with_multiple_emails), + scope: scope, + ) + end + + before do + identity.email_address_id = identity.user.email_addresses.first.id + identity.user.email_addresses.first.delete + end + + it 'defers to user alternate email' do + expect(identity.user.reload.email_addresses.first.id). + to_not eq(identity.email_address_id) + expect(identity.user.reload.email_addresses.count).to be 1 + expect(user_info[:email]).to eq(identity.user.email_addresses.last.email) + end + end + + context 'with nil email id' do + let(:identity) do + build( + :service_provider_identity, + rails_session_id: rails_session_id, + user: create(:user, :fully_registered), + scope: scope, + ) + end + + before do + identity.email_address_id = nil + end + + it 'adds the signed in email id to the identity' do + expect(user_info[:email]).to eq(identity.user.email_addresses.last.email) + end + end end end diff --git a/spec/services/attribute_asserter_spec.rb b/spec/services/attribute_asserter_spec.rb index 5ad52b0a9c0..f8e656f3e75 100644 --- a/spec/services/attribute_asserter_spec.rb +++ b/spec/services/attribute_asserter_spec.rb @@ -717,6 +717,136 @@ it_behaves_like 'unverified user' end + + context 'with a deleted email' do + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + create(:email_address, user:, email: 'email@example.com') + + ident = user.identities.last + ident.email_address_id = user.email_addresses.first.id + ident.save + subject.build + + user.email_addresses.first.delete + + subject.build + end + + it 'defers to user alternate email' do + expect(get_asserted_attribute(user, :email)). + to eq 'email@example.com' + end + end + + context 'with a nil email id' do + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + + ident = user.identities.last + ident.email_address_id = nil + ident.save + subject.build + end + + it 'defers to user alternate email' do + expect(get_asserted_attribute(user, :email)). + to eq user.email_addresses.last.email + end + end + + context 'select email to send to partner feature is disabled' do + before do + allow(IdentityConfig.store).to receive( + :feature_select_email_to_share_enabled, + ).and_return(false) + end + + context 'with a deleted email' do + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + create(:email_address, user:, email: 'email@example.com') + + ident = user.identities.last + ident.email_address_id = user.email_addresses.first.id + ident.save + subject.build + + user.email_addresses.first.delete + + subject.build + end + + it 'defers to user alternate email' do + expect(get_asserted_attribute(user, :email)). + to eq 'email@example.com' + end + end + + context 'with a nil email id' do + let(:subject) do + described_class.new( + user: user, + name_id_format: name_id_format, + service_provider: service_provider, + authn_request: authn_request, + decrypted_pii: decrypted_pii, + user_session: user_session, + ) + end + before do + user.identities << identity + allow(service_provider.metadata).to receive(:[]).with(:attribute_bundle). + and_return(%w[email phone first_name]) + + ident = user.identities.last + ident.email_address_id = nil + ident.save + subject.build + end + + it 'defers to user alternate email' do + expect(get_asserted_attribute(user, :email)). + to eq user.email_addresses.last.email + end + end + end end describe 'aal attributes handling' do diff --git a/spec/services/displayable_pii_formatter_spec.rb b/spec/services/displayable_pii_formatter_spec.rb index b62885eaeb9..e2f6aa407bf 100644 --- a/spec/services/displayable_pii_formatter_spec.rb +++ b/spec/services/displayable_pii_formatter_spec.rb @@ -48,6 +48,8 @@ ) end + let(:selected_email_id) { current_user.email_addresses.first.id } + let(:pii) do { first_name: first_name, @@ -63,7 +65,13 @@ } end - subject(:formatter) { described_class.new(current_user: current_user, pii: pii) } + subject(:formatter) do + described_class.new( + current_user:, + pii:, + selected_email_id:, + ) + end describe '#format' do context 'ial1' do diff --git a/spec/views/sign_up/completions/show.html.erb_spec.rb b/spec/views/sign_up/completions/show.html.erb_spec.rb index ee045ce15f1..352e3e293e1 100644 --- a/spec/views/sign_up/completions/show.html.erb_spec.rb +++ b/spec/views/sign_up/completions/show.html.erb_spec.rb @@ -3,6 +3,7 @@ RSpec.describe 'sign_up/completions/show.html.erb' do let(:user) { create(:user, :fully_registered) } let(:service_provider) { create(:service_provider) } + let(:selected_email_id) { user.email_addresses.first.id } let(:decrypted_pii) { {} } let(:requested_attributes) { [:email] } let(:ial2_requested) { false } @@ -22,10 +23,11 @@ CompletionsPresenter.new( current_user: user, current_sp: service_provider, - decrypted_pii: decrypted_pii, - requested_attributes: requested_attributes, - ial2_requested: ial2_requested, - completion_context: completion_context, + decrypted_pii:, + requested_attributes:, + ial2_requested:, + completion_context:, + selected_email_id:, ) end @@ -59,6 +61,48 @@ ) end + context 'select email to send to partner and select email feature is disabled' do + before do + allow(IdentityConfig.store).to receive( + :feature_select_email_to_share_enabled, + ).and_return(false) + end + + it 'does not show a link to select different email' do + create(:email_address, user: user) + user.reload + render + + expect(rendered).to_not include(t('help_text.requested_attributes.change_email_link')) + expect(rendered).to_not include(t('account.index.email_add')) + end + + it 'does not show a link to add another email' do + render + + expect(rendered).to_not include(t('help_text.requested_attributes.change_email_link')) + expect(rendered).to_not include(t('account.index.email_add')) + end + end + + context 'select email to send to partner' do + it 'does not show a link to select different email' do + create(:email_address, user: user) + user.reload + render + + expect(rendered).to include(t('help_text.requested_attributes.change_email_link')) + expect(rendered).to_not include(t('account.index.email_add')) + end + + it 'does not show a link to add another email' do + render + + expect(rendered).to_not include(t('help_text.requested_attributes.change_email_link')) + expect(rendered).to include(t('account.index.email_add')) + end + end + context 'the all_emails scope is requested' do let(:requested_attributes) { [:email, :all_emails] } diff --git a/spec/views/sign_up/select_email/show.html.erb_spec.rb b/spec/views/sign_up/select_email/show.html.erb_spec.rb new file mode 100644 index 00000000000..a699da041ae --- /dev/null +++ b/spec/views/sign_up/select_email/show.html.erb_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe 'sign_up/select_email/show.html.erb' do + let(:email) { 'michael.motorist@email.com' } + let(:email2) { 'michael.motorist2@email.com' } + let(:user) { create(:user) } + + before do + user.email_addresses.create(email: email, confirmed_at: Time.zone.now) + user.email_addresses.create(email: email2, confirmed_at: Time.zone.now) + user.reload + @user_emails = user.email_addresses + @select_email_form = SelectEmailForm.new(user) + end + + it 'shows all of the user\'s emails' do + render + + expect(rendered).to include('michael.motorist@email.com') + expect(rendered).to include('michael.motorist2@email.com') + end +end