diff --git a/app/components/tab_navigation_component.html.erb b/app/components/tab_navigation_component.html.erb
new file mode 100644
index 00000000000..c9a89a8598a
--- /dev/null
+++ b/app/components/tab_navigation_component.html.erb
@@ -0,0 +1,15 @@
+<%= content_tag(:nav, aria: { label: }, **tag_options) do %>
+
- <%= f.submit t('links.next'), full_width: true, wide: false %>
+ <%= f.submit t('links.next'), full_width: true, wide: false %>
+ <% if @sign_in_a_b_test_bucket == :default %>
<%= t('headings.create_account_with_sp.cta', app_name: APP_NAME) %>
<%= link_to(
t('links.create_account'),
- sign_up_email_url(request_id: @request_id),
- class: 'usa-button usa-button--big usa-button--outline usa-button--full-width margin-top-1',
+ sign_up_email_url(request_id: @request_id, source: :sign_in),
+ class: 'usa-button usa-button--big usa-button--outline usa-button--full-width margin-bottom-105',
) %>
-
+ <% end %>
<% end %>
<% if @ial && desktop_device? %>
-
+
<%= link_to(
t('account.login.piv_cac'),
login_piv_cac_url,
diff --git a/app/views/sign_up/registrations/new.html.erb b/app/views/sign_up/registrations/new.html.erb
index c18bca98ebf..279bd9d3712 100644
--- a/app/views/sign_up/registrations/new.html.erb
+++ b/app/views/sign_up/registrations/new.html.erb
@@ -2,7 +2,24 @@
<%= render 'shared/sp_alert', section: 'sign_up' %>
-<%= render PageHeadingComponent.new.with_content(t('titles.registrations.new')) %>
+<% if @sign_in_a_b_test_bucket == :tabbed %>
+ <% if decorated_session.sp_name %>
+ <%= render 'sign_up/registrations/sp_registration_heading' %>
+ <% end %>
+
+ <%= render TabNavigationComponent.new(
+ label: t('account.login.tab_navigation'),
+ routes: [
+ { text: t('links.next'), path: new_user_session_url(request_id: sp_session[:request_id]) },
+ { text: t('links.create_account'), path: sign_up_email_path(request_id: sp_session[:request_id]) },
+ ],
+ class: 'margin-bottom-4',
+ ) %>
+
+ <%= render PageHeadingComponent.new.with_content(t('headings.create_account_new_users')) %>
+<% else %>
+ <%= render PageHeadingComponent.new.with_content(t('titles.registrations.new')) %>
+<% end %>
<%= simple_form_for(
@register_user_email_form,
diff --git a/config/application.yml.default b/config/application.yml.default
index dc0d86c0267..25a606864d5 100644
--- a/config/application.yml.default
+++ b/config/application.yml.default
@@ -306,6 +306,7 @@ session_timeout_warning_seconds: 150
session_total_duration_timeout_in_minutes: 720
ses_configuration_set_name: ''
set_remember_device_session_expiration: false
+sign_in_a_b_testing: '{"default":100,"tabbed":0}'
sp_handoff_bounce_max_seconds: 2
show_user_attribute_deprecation_warnings: false
otp_min_attempts_remaining_warning_count: 3
diff --git a/config/initializers/ab_tests.rb b/config/initializers/ab_tests.rb
index 361813ce4f3..58279e248c4 100644
--- a/config/initializers/ab_tests.rb
+++ b/config/initializers/ab_tests.rb
@@ -19,6 +19,11 @@ module AbTests
},
)
+ SIGN_IN = AbTestBucket.new(
+ experiment_name: 'Sign In Experience',
+ buckets: IdentityConfig.store.sign_in_a_b_testing,
+ )
+
def self.in_person_cta_variant_testing_buckets
buckets = Hash.new
percents = IdentityConfig.store.in_person_cta_variant_testing_percents
diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml
index db5af6e59b9..14757b71586 100644
--- a/config/locales/account/en.yml
+++ b/config/locales/account/en.yml
@@ -73,6 +73,7 @@ en:
login:
ie_not_supported: Internet Explorer 11 is no longer supported as of %{date}
piv_cac: Sign in with your government employee ID
+ tab_navigation: Account creation tabs
navigation:
access_services: Access your government benefits and services from your
%{app_name} account.
diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml
index 6fe8b55f70a..15da56e0a52 100644
--- a/config/locales/account/es.yml
+++ b/config/locales/account/es.yml
@@ -74,6 +74,7 @@ es:
login:
ie_not_supported: Internet Explorer 11 dejó de ser compatible a partir del %{date}
piv_cac: Inicie sesión con su identificación de empleado del gobierno
+ tab_navigation: Pestañas de creación de cuenta
navigation:
access_services: Acceda a los beneficios y servicios de su gobierno desde su
cuenta %{app_name}.
diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml
index efa01267c15..7d00e071849 100644
--- a/config/locales/account/fr.yml
+++ b/config/locales/account/fr.yml
@@ -79,6 +79,7 @@ fr:
login:
ie_not_supported: Internet Explorer 11 n’est plus pris en charge à partir du %{date}
piv_cac: Connectez-vous avec votre ID d’employé du gouvernement
+ tab_navigation: Onglets de création de compte
navigation:
access_services: Accédez à vos avantages et services gouvernementaux depuis
votre compte %{app_name}.
diff --git a/config/locales/headings/en.yml b/config/locales/headings/en.yml
index e5a43d89591..767d44b2d05 100644
--- a/config/locales/headings/en.yml
+++ b/config/locales/headings/en.yml
@@ -22,6 +22,7 @@ en:
prompt: Are you sure you want to cancel?
confirmations:
new: Send another confirmation email
+ create_account_new_users: Create an account for new users
create_account_with_sp:
cta: First time using %{app_name}?
sp_text: is using %{app_name} to allow you to sign in to your account safely and
@@ -60,6 +61,7 @@ en:
new: Use your PIV/CAC card to secure your account
residential_address: Current residential address
session_timeout_warning: Need more time?
+ sign_in_existing_users: Sign in for existing users
sign_in_with_sp: Sign in to continue to %{sp}
sign_in_without_sp: Sign in
sp_handoff_bounced: There was a problem connecting to %{sp_name}
diff --git a/config/locales/headings/es.yml b/config/locales/headings/es.yml
index 01e8ebd575c..e46608025c5 100644
--- a/config/locales/headings/es.yml
+++ b/config/locales/headings/es.yml
@@ -22,6 +22,7 @@ es:
prompt: '¿Estas seguro que quieres cancelar?'
confirmations:
new: Enviar otro email de confirmación
+ create_account_new_users: Crear una cuenta para usuarios nuevos
create_account_with_sp:
cta: '¿Es la primera vez que utiliza %{app_name}?'
sp_text: está utilizando %{app_name} para permitirle iniciar sesión en su cuenta
@@ -60,6 +61,7 @@ es:
new: Use su tarjeta PIV/CAC para asegurar su cuenta
residential_address: Dirección residencial actual
session_timeout_warning: '¿Necesita más tiempo?'
+ sign_in_existing_users: Iniciar sesión para usuarios existentes
sign_in_with_sp: Iniciar sesión para continuar con %{sp}
sign_in_without_sp: Iniciar sesión
sp_handoff_bounced: Hubo un problema al conectarse a %{sp_name}
diff --git a/config/locales/headings/fr.yml b/config/locales/headings/fr.yml
index 491d5073c65..bbb66734b05 100644
--- a/config/locales/headings/fr.yml
+++ b/config/locales/headings/fr.yml
@@ -22,6 +22,7 @@ fr:
prompt: Es-tu sûre de vouloir annuler?
confirmations:
new: Envoyer un autre courriel de confirmation
+ create_account_new_users: Créer un compte pour les nouveaux utilisateurs
create_account_with_sp:
cta: Première fois que vous utilisez %{app_name}?
sp_text: utilise %{app_name} pour vous permettre de vous connecter à votre
@@ -63,6 +64,7 @@ fr:
new: Utilisez votre carte PIV/CAC pour sécuriser votre compte
residential_address: Adresse de résidence actuelle
session_timeout_warning: Vous avez besoin de plus de temps?
+ sign_in_existing_users: S’identifier pour les utilisateurs existants
sign_in_with_sp: Connectez-vous pour continuer à %{sp}
sign_in_without_sp: Connexion
sp_handoff_bounced: Un problème est survenu lors de la connexion à %{sp_name}
diff --git a/lib/identity_config.rb b/lib/identity_config.rb
index c25562d9e9d..8b6cbfa4aec 100644
--- a/lib/identity_config.rb
+++ b/lib/identity_config.rb
@@ -410,6 +410,7 @@ def self.build_store(config_map)
config.add(:ses_configuration_set_name, type: :string)
config.add(:set_remember_device_session_expiration, type: :boolean)
config.add(:show_user_attribute_deprecation_warnings, type: :boolean)
+ config.add(:sign_in_a_b_testing, type: :json, options: { symbolize_names: true })
config.add(:skip_encryption_allowed_list, type: :json)
config.add(:sp_handoff_bounce_max_seconds, type: :integer)
config.add(:state_tracking_enabled, type: :boolean)
diff --git a/spec/components/previews/tab_navigation_component_preview.rb b/spec/components/previews/tab_navigation_component_preview.rb
new file mode 100644
index 00000000000..218be364117
--- /dev/null
+++ b/spec/components/previews/tab_navigation_component_preview.rb
@@ -0,0 +1,30 @@
+class TabNavigationComponentPreview < BaseComponentPreview
+ # @!group Preview
+ def default
+ render TabNavigationComponent.new(
+ label: 'Navigation',
+ routes: [
+ { path: lookbook_path('preview'), text: 'Preview' },
+ { path: lookbook_path('workbench'), text: 'Workbench' },
+ ],
+ )
+ end
+ # @!endgroup
+
+ # @param label text
+ def workbench(label: 'Navigation')
+ render TabNavigationComponent.new(
+ label:,
+ routes: [
+ { path: lookbook_path('preview'), text: 'Preview' },
+ { path: lookbook_path('workbench'), text: 'Workbench' },
+ ],
+ )
+ end
+
+ private
+
+ def lookbook_path(example)
+ Lookbook::Engine.routes.url_helpers.lookbook_preview_path("tab_navigation/#{example}")
+ end
+end
diff --git a/spec/components/tab_navigation_component_spec.rb b/spec/components/tab_navigation_component_spec.rb
new file mode 100644
index 00000000000..46225b53aa5
--- /dev/null
+++ b/spec/components/tab_navigation_component_spec.rb
@@ -0,0 +1,82 @@
+require 'rails_helper'
+
+RSpec.describe TabNavigationComponent, type: :component do
+ let(:label) { 'Navigation' }
+ let(:routes) { [{ path: '/first', text: 'First' }, { path: '/second', text: 'Second' }] }
+ let(:tag_options) { { label:, routes: } }
+
+ subject(:rendered) do
+ render_inline TabNavigationComponent.new(**tag_options)
+ end
+
+ it 'renders labelled navigation' do
+ expect(rendered).to have_css('nav[aria-label="Navigation"]')
+ expect(rendered).to have_link('First') { |link| !is_current_link?(link) }
+ expect(rendered).to have_link('Second') { |link| !is_current_link?(link) }
+ end
+
+ context 'with tag options' do
+ let(:tag_options) { super().merge(data: { foo: 'bar' }) }
+
+ it 'renders with tag options forwarded to navigation' do
+ expect(rendered).to have_css('nav[data-foo="bar"]')
+ end
+ end
+
+ context 'with link for current request' do
+ before do
+ allow(request).to receive(:path).and_return('/first')
+ end
+
+ it 'renders current link as highlighted' do
+ expect(rendered).to have_link('First') { |link| is_current_link?(link) }
+ expect(rendered).to have_link('Second') { |link| !is_current_link?(link) }
+ end
+
+ context 'with routes defining full URL' do
+ let(:routes) do
+ [
+ { path: 'https://example.com/first', text: 'First' },
+ { path: 'https://example.com/second', text: 'Second' },
+ ]
+ end
+
+ it 'renders current link as highlighted' do
+ expect(rendered).to have_link('First') { |link| is_current_link?(link) }
+ expect(rendered).to have_link('Second') { |link| !is_current_link?(link) }
+ end
+ end
+
+ context 'with routes including query parameters' do
+ let(:routes) do
+ [
+ { path: '/first?foo=bar', text: 'First' },
+ { path: '/second?foo=bar', text: 'Second' },
+ ]
+ end
+
+ it 'renders current link as highlighted' do
+ expect(rendered).to have_link('First') { |link| is_current_link?(link) }
+ expect(rendered).to have_link('Second') { |link| !is_current_link?(link) }
+ end
+ end
+
+ context 'unparseable route' do
+ let(:routes) do
+ [
+ { path: '😬', text: 'First' },
+ { path: '😬', text: 'Second' },
+ ]
+ end
+
+ it 'renders gracefully without highlighted link' do
+ expect(rendered).to have_link('First') { |link| !is_current_link?(link) }
+ expect(rendered).to have_link('Second') { |link| !is_current_link?(link) }
+ end
+ end
+ end
+
+ def is_current_link?(link)
+ link.matches_css?('[aria-current="page"]:not(.usa-button--outline)')
+ end
+end
diff --git a/spec/controllers/concerns/sign_in_a_b_test_concern_spec.rb b/spec/controllers/concerns/sign_in_a_b_test_concern_spec.rb
new file mode 100644
index 00000000000..61acd114b21
--- /dev/null
+++ b/spec/controllers/concerns/sign_in_a_b_test_concern_spec.rb
@@ -0,0 +1,38 @@
+require 'rails_helper'
+
+RSpec.describe SignInABTestConcern, type: :controller do
+ controller ApplicationController do
+ include SignInABTestConcern
+ end
+
+ describe '#sign_in_a_b_test_bucket' do
+ subject(:sign_in_a_b_test_bucket) { controller.sign_in_a_b_test_bucket }
+
+ let(:sp_session) { {} }
+
+ before do
+ allow(session).to receive(:id).and_return('session-id')
+ allow(controller).to receive(:sp_session).and_return(sp_session)
+ allow(AbTests::SIGN_IN).to receive(:bucket) do |discriminator|
+ case discriminator
+ when 'session-id'
+ :default
+ when 'request-id'
+ :tabbed
+ end
+ end
+ end
+
+ it 'returns the bucket based on session id' do
+ expect(sign_in_a_b_test_bucket).to eq(:default)
+ end
+
+ context 'with associated sp session request id' do
+ let(:sp_session) { { request_id: 'request-id' } }
+
+ it 'returns the bucket based on request id' do
+ expect(sign_in_a_b_test_bucket).to eq(:tabbed)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/sign_up/completions_controller_spec.rb b/spec/controllers/sign_up/completions_controller_spec.rb
index d4bc5ca2325..a3525044fde 100644
--- a/spec/controllers/sign_up/completions_controller_spec.rb
+++ b/spec/controllers/sign_up/completions_controller_spec.rb
@@ -135,6 +135,7 @@
before do
stub_analytics
allow(@analytics).to receive(:track_event)
+ allow(controller).to receive(:sign_in_a_b_test_bucket).and_return(:default)
@linker = instance_double(IdentityLinker)
allow(@linker).to receive(:link_identity).and_return(true)
allow(IdentityLinker).to receive(:new).and_return(@linker)
@@ -158,6 +159,7 @@
service_provider_name: subject.decorated_session.sp_name,
page_occurence: 'agency-page',
needs_completion_screen_reason: :new_sp,
+ sign_in_a_b_test_bucket: :default,
sp_request_requested_attributes: nil,
sp_session_requested_attributes: nil,
)
@@ -217,6 +219,7 @@
service_provider_name: subject.decorated_session.sp_name,
page_occurence: 'agency-page',
needs_completion_screen_reason: :new_sp,
+ sign_in_a_b_test_bucket: :default,
sp_request_requested_attributes: nil,
sp_session_requested_attributes: ['email'],
)
diff --git a/spec/controllers/sign_up/registrations_controller_spec.rb b/spec/controllers/sign_up/registrations_controller_spec.rb
index 6f3511ad465..8a28af1c2d5 100644
--- a/spec/controllers/sign_up/registrations_controller_spec.rb
+++ b/spec/controllers/sign_up/registrations_controller_spec.rb
@@ -26,6 +26,34 @@
to raise_error(Mime::Type::InvalidMimeType)
end
+ it 'tracks visit event' do
+ stub_analytics
+ allow(controller).to receive(:sign_in_a_b_test_bucket).and_return(:default)
+
+ expect(@analytics).to receive(:track_event).with(
+ 'User Registration: enter email visited',
+ sign_in_a_b_test_bucket: :default,
+ from_sign_in: false,
+ )
+
+ get :new
+ end
+
+ context 'with source parameter' do
+ it 'tracks visit event' do
+ stub_analytics
+ allow(controller).to receive(:sign_in_a_b_test_bucket).and_return(:default)
+
+ expect(@analytics).to receive(:track_event).with(
+ 'User Registration: enter email visited',
+ sign_in_a_b_test_bucket: :default,
+ from_sign_in: true,
+ )
+
+ get :new, params: { source: :sign_in }
+ end
+ end
+
context 'IdV unavailable' do
before do
allow(IdentityConfig.store).to receive(:idv_available).and_return(false)
diff --git a/spec/controllers/users/sessions_controller_spec.rb b/spec/controllers/users/sessions_controller_spec.rb
index 6bcf286f36e..ff494bf471d 100644
--- a/spec/controllers/users/sessions_controller_spec.rb
+++ b/spec/controllers/users/sessions_controller_spec.rb
@@ -590,10 +590,15 @@
it 'tracks page visit, any alert flashes, and the Devise stored location' do
stub_analytics
allow(controller).to receive(:flash).and_return(alert: 'hello')
+ allow(controller).to receive(:sign_in_a_b_test_bucket).and_return(:default)
subject.session['user_return_to'] = mock_valid_site
- properties = { flash: 'hello', stored_location: mock_valid_site }
- expect(@analytics).to receive(:track_event).with('Sign in page visited', properties)
+ expect(@analytics).to receive(:track_event).with(
+ 'Sign in page visited',
+ flash: 'hello',
+ stored_location: mock_valid_site,
+ sign_in_a_b_test_bucket: :default,
+ )
get :new
end
diff --git a/spec/support/features/session_helper.rb b/spec/support/features/session_helper.rb
index b1e4d6e8b47..2db90c3916d 100644
--- a/spec/support/features/session_helper.rb
+++ b/spec/support/features/session_helper.rb
@@ -390,11 +390,11 @@ def sign_up_user_from_sp_without_confirming_email(email)
click_sign_in_from_landing_page_then_click_create_account
- expect(current_url).to eq sign_up_email_url(request_id: sp_request_id)
+ expect(current_url).to eq sign_up_email_url(request_id: sp_request_id, source: :sign_in)
visit_landing_page_and_click_create_account_with_request_id(sp_request_id)
- expect(current_url).to eq sign_up_email_url(request_id: sp_request_id)
+ expect(current_url).to eq sign_up_email_url(request_id: sp_request_id, source: :sign_in)
expect_branded_experience
submit_form_with_invalid_email
diff --git a/spec/views/devise/sessions/new.html.erb_spec.rb b/spec/views/devise/sessions/new.html.erb_spec.rb
index c67741e89df..e9f5c5c3854 100644
--- a/spec/views/devise/sessions/new.html.erb_spec.rb
+++ b/spec/views/devise/sessions/new.html.erb_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe 'devise/sessions/new.html.erb' do
+ let(:sign_in_a_b_test_bucket) { :default }
+
before do
allow(view).to receive(:resource).and_return(build_stubbed(:user))
allow(view).to receive(:resource_name).and_return(:user)
@@ -9,6 +11,7 @@
allow(view).to receive(:decorated_session).and_return(SessionDecorator.new)
allow_any_instance_of(ActionController::TestRequest).to receive(:path).
and_return('/')
+ @sign_in_a_b_test_bucket = sign_in_a_b_test_bucket
assign(:ial, 1)
end
@@ -30,10 +33,10 @@
render
end
- it 'includes a link to log in' do
+ it 'has a localized page heading' do
render
- expect(rendered).to have_content(t('headings.sign_in_without_sp'))
+ expect(rendered).to have_selector('h1', text: t('headings.sign_in_without_sp'))
end
it 'includes a link to create a new account' do
@@ -41,7 +44,7 @@
expect(rendered).
to have_link(
- t('links.create_account'), href: sign_up_email_url(request_id: nil)
+ t('links.create_account'), href: sign_up_email_url(request_id: nil, source: :sign_in)
)
end
@@ -170,4 +173,23 @@
expect(rendered).to have_selector('input.email')
end
end
+
+ context 'with tabbed layout A/B test' do
+ let(:sign_in_a_b_test_bucket) { :tabbed }
+
+ it 'has a localized page heading' do
+ render
+
+ expect(rendered).to have_selector('h1', text: t('headings.sign_in_existing_users'))
+ end
+
+ it 'includes a link to create a new account' do
+ render
+
+ expect(rendered).to have_link(
+ t('links.create_account'),
+ href: sign_up_email_url(request_id: nil, source: :sign_in),
+ )
+ end
+ end
end
diff --git a/spec/views/sign_up/registrations/new.html.erb_spec.rb b/spec/views/sign_up/registrations/new.html.erb_spec.rb
index de563bc00a1..b86a300aebd 100644
--- a/spec/views/sign_up/registrations/new.html.erb_spec.rb
+++ b/spec/views/sign_up/registrations/new.html.erb_spec.rb
@@ -1,6 +1,8 @@
require 'rails_helper'
describe 'sign_up/registrations/new.html.erb' do
+ let(:sign_in_a_b_test_bucket) { :default }
+
let(:sp) do
build_stubbed(
:service_provider,
@@ -8,12 +10,14 @@
return_to_sp_url: 'www.awesomeness.com',
)
end
+
before do
allow(view).to receive(:current_user).and_return(nil)
@register_user_email_form = RegisterUserEmailForm.new(
analytics: FakeAnalytics.new,
attempts_tracker: IrsAttemptsApiTrackingHelper::FakeAttemptsTracker.new,
)
+ @sign_in_a_b_test_bucket = sign_in_a_b_test_bucket
view_context = ActionController::Base.new.view_context
allow(view_context).to receive(:new_user_session_url).
and_return('https://www.example.com/')
@@ -37,7 +41,7 @@
render
end
- it 'has a localized header' do
+ it 'has a localized page heading' do
render
expect(rendered).to have_selector('h1', text: t('titles.registrations.new'))
@@ -75,4 +79,23 @@
[target='_blank'][rel='noopener noreferrer']",
)
end
+
+ context 'with tabbed layout A/B test' do
+ let(:sign_in_a_b_test_bucket) { :tabbed }
+
+ it 'has a localized page heading' do
+ render
+
+ expect(rendered).to have_selector('h1', text: t('headings.create_account_new_users'))
+ end
+
+ it 'includes a link to sign in' do
+ render
+
+ expect(rendered).to have_link(
+ t('links.next'),
+ href: new_user_session_url(request_id: nil),
+ )
+ end
+ end
end