- <% MfaContext.new(current_user).webauthn_platform_configurations.each do |cfg| %>
-
-
- <%= cfg.name %>
-
- <% if MfaPolicy.new(current_user).multiple_factors_enabled? %>
-
- <%= link_to(
- t('account.index.webauthn_platform_delete'),
- webauthn_setup_delete_path(id: cfg.id),
- ) %>
-
- <% end %>
-
-
+
+ <% MfaContext.new(current_user).webauthn_platform_configurations.each do |configuration| %>
+ <%= render ManageableAuthenticatorComponent.new(
+ configuration:,
+ user_session:,
+ manage_url: edit_webauthn_path(id: configuration.id),
+ manage_api_url: api_internal_two_factor_authentication_webauthn_path(id: configuration.id),
+ custom_strings: {
+ deleted: t('two_factor_authentication.webauthn_platform.deleted'),
+ renamed: t('two_factor_authentication.webauthn_platform.renamed'),
+ manage_accessible_label: t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ },
+ role: 'list-item',
+ ) %>
<% end %>
+
<%= render ButtonComponent.new(
action: ->(**tag_options, &block) do
link_to(webauthn_setup_path(platform: true), **tag_options, &block)
@@ -27,4 +26,4 @@
icon: :add,
outline: true,
class: 'margin-top-2',
- ).with_content(t('account.index.webauthn_platform_add')) %>
\ No newline at end of file
+ ).with_content(t('account.index.webauthn_platform_add')) %>
diff --git a/config/locales/account/en.yml b/config/locales/account/en.yml
index dd406b2a2a3..e34c1204fbf 100644
--- a/config/locales/account/en.yml
+++ b/config/locales/account/en.yml
@@ -59,7 +59,6 @@ en:
webauthn_platform: Face or touch unlock
webauthn_platform_add: Add face or touch unlock
webauthn_platform_confirm_delete: Yes, remove face or touch unlock
- webauthn_platform_delete: Remove face or touch unlock
items:
delete_your_account: Delete your account
personal_key: Personal key
diff --git a/config/locales/account/es.yml b/config/locales/account/es.yml
index 4ce078ee5a6..9e72845a8cf 100644
--- a/config/locales/account/es.yml
+++ b/config/locales/account/es.yml
@@ -60,7 +60,6 @@ es:
webauthn_platform: El desbloqueo facial o táctil
webauthn_platform_add: Añadir el desbloqueo facial o táctil
webauthn_platform_confirm_delete: Si, quitar el desbloqueo facial o táctil
- webauthn_platform_delete: Quitar el desbloqueo facial o táctil
items:
delete_your_account: Eliminar su cuenta
personal_key: Clave personal
diff --git a/config/locales/account/fr.yml b/config/locales/account/fr.yml
index 0d1e8228975..445776e7d5a 100644
--- a/config/locales/account/fr.yml
+++ b/config/locales/account/fr.yml
@@ -64,8 +64,6 @@ fr:
webauthn_platform_add: Ajouter le déverouillage facial ou déverrouillage par empreinte digitale
webauthn_platform_confirm_delete: Oui, supprimer le déverouillage facial ou
déverrouillage par empreinte digitale
- webauthn_platform_delete: Supprimer le déverouillage facial ou déverrouillage
- par empreinte digitale
items:
delete_your_account: Supprimer votre compte
personal_key: Clé personnelle
diff --git a/config/locales/components/en.yml b/config/locales/components/en.yml
index 59f17f87136..184451facc0 100644
--- a/config/locales/components/en.yml
+++ b/config/locales/components/en.yml
@@ -19,6 +19,21 @@ en:
enabled_alert: You have enabled JavaScript in your browser.
next_step: Please refresh this page once you have enabled JavaScript in your
browser.
+ manageable_authenticator:
+ cancel: Cancel
+ created_on: Created on %{date}
+ delete: Delete
+ delete_confirm: Are you sure you want to delete this authentication method?
+ deleted: Successfully deleted an authentication method
+ deleting: Deleting…
+ done: Done
+ manage: Manage
+ manage_accessible_label: Manage authentication method
+ nickname: Nickname
+ rename: Rename
+ renamed: Successfully renamed your authentication method
+ save: Save
+ saving: Saving…
memorable_date:
day: Day
errors:
diff --git a/config/locales/components/es.yml b/config/locales/components/es.yml
index 2ab24b44680..40dc2a2fb34 100644
--- a/config/locales/components/es.yml
+++ b/config/locales/components/es.yml
@@ -19,6 +19,21 @@ es:
enabled_alert: YHabilitó el JavaScript en su navegador.
next_step: Recargue esta página una vez que haya habilitado JavaScript en su
navegador.
+ manageable_authenticator:
+ cancel: Cancelar
+ created_on: Creado el %{date}
+ delete: Borrar
+ delete_confirm: ¿Está seguro que desea eliminar este método de autenticación?
+ deleted: Se ha eliminado correctamente un método de autenticación.
+ deleting: Eliminando…
+ done: Terminado
+ manage: Administrar
+ manage_accessible_label: Gestionar método de autenticación
+ nickname: Apodo
+ rename: Cambiar el nombre
+ renamed: Se ha cambiado correctamente el nombre de su método de autenticación.
+ save: Guardar
+ saving: Guardando…
memorable_date:
day: Día
errors:
diff --git a/config/locales/components/fr.yml b/config/locales/components/fr.yml
index bd3b43b958f..ff7e1cfbe29 100644
--- a/config/locales/components/fr.yml
+++ b/config/locales/components/fr.yml
@@ -19,6 +19,21 @@ fr:
enabled_alert: Vous avez activé JavaScript dans votre navigateur.
next_step: Veuillez rafraîchir cette page une fois que vous avez activé
JavaScript dans votre navigateur.
+ manageable_authenticator:
+ cancel: Annuler
+ created_on: Créé le %{date}
+ delete: Effacer
+ delete_confirm: Êtes-vous sûr de vouloir supprimer cette méthode d’authentification?
+ deleted: Suppression réussie d’une méthode d’authentification
+ deleting: Suppression en cours…
+ done: Terminé
+ manage: Administrer
+ manage_accessible_label: Gérer la méthode d’authentification
+ nickname: Pseudo
+ rename: Renommer
+ renamed: Votre méthode d’authentification a été renommée avec succès
+ save: Sauvegarder
+ saving: Sauvegarde en cours…
memorable_date:
day: Jour
errors:
diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml
index c2989da29a3..0dd6bd01859 100644
--- a/config/locales/two_factor_authentication/en.yml
+++ b/config/locales/two_factor_authentication/en.yml
@@ -195,6 +195,7 @@ en:
delete: Delete this device
deleted: Successfully deleted a face or touch unlock method
edit_heading: Manage your face or touch unlock settings
+ manage_accessible_label: Manage face or touch unlock
nickname: Nickname
renamed: Successfully renamed your face or touch unlock method
webauthn_platform_header_text: Use face or touch unlock
diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml
index 4b8c090f1dc..01ef2bc2814 100644
--- a/config/locales/two_factor_authentication/es.yml
+++ b/config/locales/two_factor_authentication/es.yml
@@ -208,6 +208,7 @@ es:
delete: Eliminar este dispositivo
deleted: Se ha eliminado correctamente un método de desbloqueo facial o táctil
edit_heading: Gestione su configuración de desbloqueo facial o táctil
+ manage_accessible_label: Gestionar desbloqueo facial o táctil
nickname: Apodo
renamed: Se ha cambiado correctamente el nombre de su método de desbloqueo
facial o táctil
diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml
index 22472268a2b..bca8d4bb545 100644
--- a/config/locales/two_factor_authentication/fr.yml
+++ b/config/locales/two_factor_authentication/fr.yml
@@ -220,6 +220,8 @@ fr:
faciale ou par empreinte digitale
edit_heading: Gérez vos paramètres de déverrouillage par reconnaissance faciale
ou par empreinte digitale
+ manage_accessible_label: Gérer le déverrouillage par reconnaissance faciale ou
+ par empreinte digitale
nickname: Pseudo
renamed: Votre méthode de déverrouillage par reconnaissance faciale ou par
empreinte digitale a été renommée avec succès
diff --git a/spec/components/manageable_authenticator_component_spec.rb b/spec/components/manageable_authenticator_component_spec.rb
new file mode 100644
index 00000000000..20d438abf24
--- /dev/null
+++ b/spec/components/manageable_authenticator_component_spec.rb
@@ -0,0 +1,158 @@
+require 'rails_helper'
+
+RSpec.describe ManageableAuthenticatorComponent, type: :component do
+ include Rails.application.routes.url_helpers
+
+ let(:configuration_name) { 'Example Configuration' }
+ let(:configuration) { create(:webauthn_configuration, name: configuration_name) }
+ let(:user_session) { {} }
+ let(:reauthenticate_at) { Time.zone.now }
+ let(:manage_api_url) { '/api/manage' }
+ let(:manage_url) { '/manage' }
+ let(:options) { { configuration:, user_session:, manage_api_url:, manage_url: } }
+ let(:component) { ManageableAuthenticatorComponent.new(**options) }
+ subject(:rendered) { render_inline component }
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ before do
+ auth_methods_session = instance_double('AuthMethodsSession', reauthenticate_at:)
+ allow(component).to receive(:auth_methods_session).and_return(auth_methods_session)
+ end
+
+ it 'renders with initializing attributes' do
+ rendered
+
+ element = page.find_css('lg-manageable-authenticator').first
+
+ expect(element.attr('api-url')).to eq(manage_api_url)
+ expect(element.attr('configuration-name')).to eq(configuration_name)
+ expect(element.attr('unique-id')).to eq(component.unique_id)
+ expect(element.attr('reauthenticate-at')).to eq(reauthenticate_at.iso8601)
+ expect(element.attr('reauthentication-url')).to eq(component.reauthentication_url)
+ end
+
+ it 'renders with initializing javascript strings' do
+ rendered
+
+ strings_element = page.find_css(
+ 'script.manageable-authenticator__strings[type="application/json"]',
+ visible: false,
+ ).first
+
+ expect(JSON.parse(strings_element.text, symbolize_names: true)).to eq(
+ renamed: t('components.manageable_authenticator.renamed'),
+ deleteConfirm: t('components.manageable_authenticator.delete_confirm'),
+ deleted: t('components.manageable_authenticator.deleted'),
+ )
+ end
+
+ it 'renders with focusable edit panel' do
+ rendered
+
+ edit_element = page.find_css('.manageable-authenticator__edit').first
+
+ expect(edit_element.attr('tabindex')).to be_present
+ expect(edit_element).to have_name(
+ format(
+ '%s: %s',
+ t('components.manageable_authenticator.manage_accessible_label'),
+ configuration.name,
+ ),
+ )
+ end
+
+ it 'initializes content with configuration details' do
+ expect(rendered).to have_field(
+ t('components.manageable_authenticator.nickname'),
+ with: configuration_name,
+ )
+ expect(rendered).to have_content(configuration_name)
+ expect(rendered).to have_content(
+ t(
+ 'components.manageable_authenticator.created_on',
+ date: l(configuration.created_at, format: :event_date),
+ ),
+ )
+ end
+
+ it 'renders with buttons that have accessibly distinct manage label' do
+ expect(rendered).to have_button(
+ format(
+ '%s: %s',
+ t('components.manageable_authenticator.manage_accessible_label'),
+ configuration.name,
+ ),
+ )
+ end
+
+ describe '#reauthentication_url' do
+ subject(:reauthentication_url) { component.reauthentication_url }
+
+ it 'includes manage_authenticator query parameter for configuration' do
+ rendered
+
+ uri = URI.parse(reauthentication_url)
+ params = CGI.parse(uri.query)
+
+ expect(uri.path).to eq(account_reauthentication_path)
+ expect(params['manage_authenticator']).to eq(["webauthnconfiguration-#{configuration.id}"])
+ end
+ end
+
+ describe '#unique_id' do
+ subject(:unique_id) { component.unique_id }
+
+ it 'derives an id from the configuration class and id' do
+ rendered
+
+ expect(component.unique_id).to eq("webauthnconfiguration-#{configuration.id}")
+ end
+ end
+
+ describe '#strings' do
+ it 'includes default strings' do
+ rendered
+
+ expect(component.strings).to eq(
+ renamed: t('components.manageable_authenticator.renamed'),
+ delete_confirm: t('components.manageable_authenticator.delete_confirm'),
+ deleted: t('components.manageable_authenticator.deleted'),
+ manage_accessible_label: t('components.manageable_authenticator.manage_accessible_label'),
+ )
+ end
+
+ context 'with custom strings' do
+ let(:custom_rename_string) { 'custom rename string' }
+ let(:custom_strings) { { renamed: custom_rename_string } }
+ let(:options) do
+ { configuration:, user_session:, manage_api_url:, manage_url:, custom_strings: }
+ end
+
+ it 'overrides the default strings with provided custom strings' do
+ rendered
+
+ expect(component.strings).to eq(
+ renamed: custom_rename_string,
+ delete_confirm: t('components.manageable_authenticator.delete_confirm'),
+ deleted: t('components.manageable_authenticator.deleted'),
+ manage_accessible_label: t('components.manageable_authenticator.manage_accessible_label'),
+ )
+ end
+
+ context 'with custom manage accessible label' do
+ let(:custom_manage_accessible_label) { 'Manage' }
+ let(:custom_strings) { { manage_accessible_label: custom_manage_accessible_label } }
+
+ it 'overrides button label and affected linked content' do
+ manage_label = format('%s: %s', custom_manage_accessible_label, configuration.name)
+ expect(rendered).to have_button(manage_label)
+ edit_element = page.find_css('.manageable-authenticator__edit').first
+ expect(edit_element).to have_name(manage_label)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/components/spinner_button_component_spec.rb b/spec/components/spinner_button_component_spec.rb
index 3b468732aca..39266558cf7 100644
--- a/spec/components/spinner_button_component_spec.rb
+++ b/spec/components/spinner_button_component_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe SpinnerButtonComponent, type: :component do
- it 'renders a button with the given content and tag options' do
+ it 'renders a button with the given content and button options' do
render_inline SpinnerButtonComponent.new(
outline: true,
data: { foo: 'bar' },
@@ -22,6 +22,16 @@
expect(page).to_not have_selector('[spin-on-click]')
end
+ it 'renders default long-duration-wait-ms attribute' do
+ render_inline SpinnerButtonComponent.new.with_content('')
+
+ element = page.find_css('lg-spinner-button').first
+
+ expect(element['long-wait-duration-ms']).to eq(
+ SpinnerButtonComponent::DEFAULT_LONG_WAIT_DURATION.in_milliseconds.to_s,
+ )
+ end
+
context 'with action message' do
it 'renders with action message' do
rendered = render_inline SpinnerButtonComponent.new(
@@ -46,4 +56,36 @@
expect(page).to have_selector('[spin-on-click=true]')
end
end
+
+ context 'with custom long wait duration' do
+ it 'renders with customized long-duration-wait-ms attribute' do
+ render_inline SpinnerButtonComponent.new(long_wait_duration: 1.second).with_content('')
+
+ element = page.find_css('lg-spinner-button').first
+
+ expect(element['long-wait-duration-ms']).to eq('1000')
+ end
+ end
+
+ context 'with wrapper options' do
+ it 'renders wrapper with given options' do
+ render_inline SpinnerButtonComponent.new(
+ wrapper_options: { data: { foo: 'bar' } },
+ outline: true,
+ ).with_content('')
+
+ expect(page).to have_css('lg-spinner-button[data-foo="bar"]')
+ end
+
+ context 'with outline button' do
+ it 'renders with both customized class and outline class' do
+ rendered = render_inline SpinnerButtonComponent.new(
+ wrapper_options: { class: 'example-class' },
+ outline: true,
+ ).with_content('')
+
+ expect(rendered).to have_css('lg-spinner-button.example-class.spinner-button--outline')
+ end
+ end
+ end
end
diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb
index 98f95a18a0a..e588287857c 100644
--- a/spec/controllers/accounts_controller_spec.rb
+++ b/spec/controllers/accounts_controller_spec.rb
@@ -178,6 +178,7 @@
before(:each) do
stub_sign_in(user)
end
+
it 'redirects to 2FA options' do
post :reauthentication
@@ -195,5 +196,28 @@
expect(controller.user_session[:stored_location]).to eq account_url
end
+
+ context 'with parameters' do
+ let(:params) { { foo: 'bar' } }
+
+ it 'sets stored location excluding unknown parameters' do
+ post :reauthentication, params: params
+
+ expect(controller.user_session[:stored_location]).to eq account_url
+ end
+
+ context 'with permitted parameters' do
+ let(:manage_authenticator_param) { 'abc-123' }
+ let(:params) { { foo: 'bar', manage_authenticator: manage_authenticator_param } }
+
+ it 'sets stored location including only permitted parameters' do
+ post :reauthentication, params: params
+
+ expect(controller.user_session[:stored_location]).to eq(
+ account_url(manage_authenticator: manage_authenticator_param),
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/features/webauthn/management_spec.rb b/spec/features/webauthn/management_spec.rb
index 0d712e9bd7b..c14da779103 100644
--- a/spec/features/webauthn/management_spec.rb
+++ b/spec/features/webauthn/management_spec.rb
@@ -94,23 +94,6 @@ def expect_webauthn_platform_setup_error
expect(user.reload.webauthn_configurations.empty?).to eq(true)
end
- it 'allows the user to cancel deletion of the security key' do
- webauthn_config = create(:webauthn_configuration, user: user)
-
- sign_in_and_2fa_user(user)
- visit account_two_factor_authentication_path
-
- expect(page).to have_content webauthn_config.name
-
- click_link t('account.index.webauthn_delete')
-
- expect(current_path).to eq webauthn_setup_delete_path
-
- click_link t('links.cancel')
-
- expect(page).to have_content webauthn_config.name
- end
-
it 'prevents a user from deleting the last key' do
webauthn_config = create(:webauthn_configuration, user: user)
@@ -184,52 +167,145 @@ def expect_webauthn_platform_setup_error
it 'allows user to delete a platform authenticator when another 2FA option is set up' do
webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
sign_in_and_2fa_user(user)
visit account_two_factor_authentication_path
- expect(page).to have_content webauthn_config.name
+ expect(page).to have_content(name)
- click_link t('account.index.webauthn_platform_delete')
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
- expect(current_path).to eq webauthn_setup_delete_path
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
- click_button t('account.index.webauthn_platform_confirm_delete')
+ click_button t('two_factor_authentication.webauthn_platform.delete')
- expect(page).to_not have_content webauthn_config.name
- expect(page).to have_content t('notices.webauthn_platform_deleted')
+ expect(page).to_not have_content(name)
+ expect(page).to have_content(t('two_factor_authentication.webauthn_platform.deleted'))
expect(user.reload.webauthn_configurations.empty?).to eq(true)
end
+ it 'allows user to rename a platform authenticator' do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+ visit account_two_factor_authentication_path
+
+ expect(page).to have_content(name)
+
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
+ expect(page).to have_field(
+ t('two_factor_authentication.webauthn_platform.nickname'),
+ with: name,
+ )
+
+ fill_in t('two_factor_authentication.webauthn_platform.nickname'), with: 'new name'
+
+ click_button t('two_factor_authentication.webauthn_platform.change_nickname')
+
+ expect(page).to have_content('new name')
+ expect(page).to have_content(t('two_factor_authentication.webauthn_platform.renamed'))
+ end
+
it 'allows the user to cancel deletion of the platform authenticator' do
webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
sign_in_and_2fa_user(user)
visit account_two_factor_authentication_path
- expect(page).to have_content webauthn_config.name
+ expect(page).to have_content(name)
- click_link t('account.index.webauthn_platform_delete')
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
- expect(current_path).to eq webauthn_setup_delete_path
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
click_link t('links.cancel')
- expect(page).to have_content webauthn_config.name
+ expect(page).to have_content(name)
end
it 'prevents a user from deleting the last key' do
webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
sign_in_and_2fa_user(user)
PhoneConfiguration.first.update(mfa_enabled: false)
user.backup_code_configurations.destroy_all
- visit account_two_factor_authentication_path
- expect(current_path).to eq account_two_factor_authentication_path
+ expect(page).to have_content(name)
- expect(page).to have_content webauthn_config.name
- expect(page).to_not have_link t('account.index.webauthn_platform_delete')
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
+
+ click_button t('two_factor_authentication.webauthn_platform.delete')
+
+ expect(page).to have_current_path(edit_webauthn_path(id: webauthn_config.id))
+ expect(page).to have_content(t('errors.manage_authenticator.remove_only_method_error'))
+ expect(user.reload.webauthn_configurations.empty?).to eq(false)
+ end
+
+ it 'requires a user to use a unique name when renaming' do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ create(:webauthn_configuration, :platform_authenticator, user:, name: 'existing')
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+
+ expect(page).to have_content(name)
+
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
+ expect(page).to have_field(
+ t('two_factor_authentication.webauthn_platform.nickname'),
+ with: name,
+ )
+
+ fill_in t('two_factor_authentication.webauthn_platform.nickname'), with: 'existing'
+
+ click_button t('two_factor_authentication.webauthn_platform.change_nickname')
+
+ expect(current_path).to eq(edit_webauthn_path(id: webauthn_config.id))
+ expect(page).to have_field(
+ t('two_factor_authentication.webauthn_platform.nickname'),
+ with: 'existing',
+ )
+ expect(page).to have_content(t('errors.manage_authenticator.unique_name_error'))
end
it 'gives an error if name is taken and stays on the configuration screen' do
@@ -250,5 +326,123 @@ def expect_webauthn_platform_setup_error
expect(current_path).to eq webauthn_setup_path
expect(page).to have_content t('errors.webauthn_platform_setup.unique_name')
end
+
+ context 'with javascript enabled', :js do
+ it 'allows user to delete a platform authenticator when another 2FA option is set up' do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+ visit account_two_factor_authentication_path
+
+ expect(page).to have_content(name)
+
+ click_button(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ # Verify user can cancel deletion. There's an implied assertion here that the button becomes
+ # clickable again, since the following confirmation occurs upon successive button click.
+ dismiss_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') }
+
+ # Verify user confirms deletion
+ accept_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') }
+
+ expect(page).to have_content(
+ t('two_factor_authentication.webauthn_platform.deleted'),
+ wait: 5,
+ )
+ expect(page).to_not have_content(name)
+ expect(user.reload.webauthn_configurations.empty?).to eq(true)
+ end
+
+ it 'allows user to rename a platform authenticator' do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+ visit account_two_factor_authentication_path
+
+ expect(page).to have_content(name)
+
+ click_button(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+ click_button t('components.manageable_authenticator.rename')
+
+ expect(page).to have_field(t('components.manageable_authenticator.nickname'), with: name)
+
+ fill_in t('components.manageable_authenticator.nickname'), with: 'new name'
+
+ click_button t('components.manageable_authenticator.save')
+
+ expect(page).to have_content(
+ t('two_factor_authentication.webauthn_platform.renamed'),
+ wait: 5,
+ )
+ expect(page).to have_content('new name')
+ end
+
+ it 'prevents a user from deleting the last key', allow_browser_log: true do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+ PhoneConfiguration.first.update(mfa_enabled: false)
+ user.backup_code_configurations.destroy_all
+
+ expect(page).to have_content(name)
+
+ click_button(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+ accept_confirm(wait: 5) { click_button t('components.manageable_authenticator.delete') }
+
+ expect(page).to have_content(
+ t('errors.manage_authenticator.remove_only_method_error'),
+ wait: 5,
+ )
+ expect(user.reload.webauthn_configurations.empty?).to eq(false)
+ end
+
+ it 'requires a user to use a unique name when renaming', allow_browser_log: true do
+ webauthn_config = create(:webauthn_configuration, :platform_authenticator, user:)
+ create(:webauthn_configuration, :platform_authenticator, user:, name: 'existing')
+ name = webauthn_config.name
+
+ sign_in_and_2fa_user(user)
+
+ expect(page).to have_content(name)
+
+ click_button(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.webauthn_platform.manage_accessible_label'),
+ name,
+ ),
+ )
+ click_button t('components.manageable_authenticator.rename')
+
+ expect(page).to have_field(t('components.manageable_authenticator.nickname'), with: name)
+
+ fill_in t('components.manageable_authenticator.nickname'), with: 'existing'
+
+ click_button t('components.manageable_authenticator.save')
+
+ expect(page).to have_content(t('errors.manage_authenticator.unique_name_error'), wait: 5)
+ end
+ end
end
end
diff --git a/spec/javascript/spec_helper.d.ts b/spec/javascript/spec_helper.d.ts
index b0c66d489bc..901ef4ed7f8 100644
--- a/spec/javascript/spec_helper.d.ts
+++ b/spec/javascript/spec_helper.d.ts
@@ -1,5 +1,8 @@
-import { expect as _expect } from 'chai';
+import type { expect as _expect } from 'chai';
+import type { JSDOM } from 'jsdom';
declare global {
const expect: typeof _expect;
+
+ const jsdom: JSDOM;
}
diff --git a/spec/javascript/spec_helper.js b/spec/javascript/spec_helper.js
index b9ba8ace5b2..13f89e087e2 100644
--- a/spec/javascript/spec_helper.js
+++ b/spec/javascript/spec_helper.js
@@ -18,6 +18,7 @@ global.expect = chai.expect;
// Emulate a DOM, since many modules will assume the presence of these globals exist as a side
// effect of their import.
const dom = createDOM();
+global.jsdom = dom;
global.window = dom.window;
const windowGlobals = Object.fromEntries(
Object.getOwnPropertyNames(window)
diff --git a/spec/services/auth_methods_session_spec.rb b/spec/services/auth_methods_session_spec.rb
index 1de85a733aa..eca84837bcf 100644
--- a/spec/services/auth_methods_session_spec.rb
+++ b/spec/services/auth_methods_session_spec.rb
@@ -103,6 +103,164 @@
end
end
+ describe '#last_2fa_auth_event' do
+ subject(:last_2fa_auth_event) { auth_methods_session.last_2fa_auth_event }
+
+ context 'no auth events' do
+ it { expect(last_2fa_auth_event).to be_nil }
+ end
+
+ context 'with remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: Time.zone.now,
+ },
+ ],
+ }
+ end
+
+ it { expect(last_2fa_auth_event).to be_nil }
+
+ context 'with non-remember device auth event' do
+ let(:auth_event_2fa) do
+ { auth_method: TwoFactorAuthenticatable::AuthMethod::SMS, at: Time.zone.now }
+ end
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: 3.minutes.ago,
+ },
+ auth_event_2fa,
+ ],
+ }
+ end
+
+ it 'is the non-remember device auth event' do
+ expect(last_2fa_auth_event).to eq(auth_event_2fa)
+ end
+ end
+ end
+
+ context 'with non-remember device auth event' do
+ let(:auth_event_2fa) do
+ { auth_method: TwoFactorAuthenticatable::AuthMethod::SMS, at: Time.zone.now }
+ end
+ let(:user_session) { { auth_events: [auth_event_2fa] } }
+
+ it 'is the non-remember device auth event' do
+ expect(last_2fa_auth_event).to eq(auth_event_2fa)
+ end
+ end
+ end
+
+ describe '#reauthenticate_at' do
+ subject(:reauthenticate_at) { auth_methods_session.reauthenticate_at }
+
+ context 'no auth events' do
+ it 'is now' do
+ expect(reauthenticate_at).to eq(Time.zone.now)
+ end
+ end
+
+ context 'with remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: Time.zone.now,
+ },
+ ],
+ }
+ end
+
+ it 'is now' do
+ expect(reauthenticate_at).to eq(Time.zone.now)
+ end
+
+ context 'with expired non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: (IdentityConfig.store.reauthn_window.seconds + 2).ago,
+ },
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds + 1).ago,
+ },
+ ],
+ }
+ end
+
+ it 'is at time of 2fa auth event expiration' do
+ expect(reauthenticate_at).to eq(Time.zone.now - 1.second)
+ end
+ end
+
+ context 'with valid non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: (IdentityConfig.store.reauthn_window.seconds - 2).ago,
+ },
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds - 1).ago,
+ },
+ ],
+ }
+ end
+
+ it 'is at time of 2fa auth event expiration' do
+ expect(reauthenticate_at).to eq(Time.zone.now + 1.second)
+ end
+ end
+ end
+
+ context 'with expired non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds + 1).ago,
+ },
+ ],
+ }
+ end
+
+ it 'is at time of 2fa auth event expiration' do
+ expect(reauthenticate_at).to eq(Time.zone.now - 1.second)
+ end
+ end
+
+ context 'with valid non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds - 1).ago,
+ },
+ ],
+ }
+ end
+
+ it 'is at time of 2fa auth event expiration' do
+ expect(reauthenticate_at).to eq(Time.zone.now + 1.second)
+ end
+ end
+ end
+
describe '#recently_authenticated_2fa?' do
subject(:recently_authenticated_2fa) { auth_methods_session.recently_authenticated_2fa? }
@@ -124,17 +282,36 @@
it { expect(recently_authenticated_2fa).to eq(false) }
- context 'with non-remember device auth event' do
+ context 'with expired non-remember device auth event' do
let(:user_session) do
{
auth_events: [
{
auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
- at: 3.minutes.ago,
+ at: (IdentityConfig.store.reauthn_window.seconds + 2).ago,
+ },
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds + 1).ago,
+ },
+ ],
+ }
+ end
+
+ it { expect(recently_authenticated_2fa).to eq(false) }
+ end
+
+ context 'with valid non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::REMEMBER_DEVICE,
+ at: (IdentityConfig.store.reauthn_window.seconds - 2).ago,
},
{
auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
- at: Time.zone.now,
+ at: (IdentityConfig.store.reauthn_window.seconds - 1).ago,
},
],
}
@@ -144,13 +321,28 @@
end
end
- context 'with non-remember device auth event' do
+ context 'with expired non-remember device auth event' do
let(:user_session) do
{
auth_events: [
{
auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
- at: Time.zone.now,
+ at: (IdentityConfig.store.reauthn_window.seconds + 1).ago,
+ },
+ ],
+ }
+ end
+
+ it { expect(recently_authenticated_2fa).to eq(false) }
+ end
+
+ context 'with valid non-remember device auth event' do
+ let(:user_session) do
+ {
+ auth_events: [
+ {
+ auth_method: TwoFactorAuthenticatable::AuthMethod::SMS,
+ at: (IdentityConfig.store.reauthn_window.seconds - 1).ago,
},
],
}
diff --git a/spec/support/matchers/accessibility.rb b/spec/support/matchers/accessibility.rb
index 42e7be70ff8..19659c2b50c 100644
--- a/spec/support/matchers/accessibility.rb
+++ b/spec/support/matchers/accessibility.rb
@@ -130,7 +130,7 @@ def descriptors(element)
end
RSpec::Matchers.define :have_name do |name|
- match { |element| AccessibleName.new(page:).computed_name(element) == name }
+ match { |element| AccessibleName.new(page:).computed_name(element).strip == name.strip }
failure_message do |element|
<<-STR.squish
diff --git a/spec/views/accounts/_webauthn_platform.html.erb_spec.rb b/spec/views/accounts/_webauthn_platform.html.erb_spec.rb
new file mode 100644
index 00000000000..cce84351b84
--- /dev/null
+++ b/spec/views/accounts/_webauthn_platform.html.erb_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+RSpec.describe 'accounts/_webauthn_platform.html.erb' do
+ let(:user) do
+ create(
+ :user,
+ webauthn_configurations: create_list(:webauthn_configuration, 2, :platform_authenticator),
+ )
+ end
+ let(:user_session) { { auth_events: [] } }
+
+ subject(:rendered) { render partial: 'accounts/webauthn_platform' }
+
+ before do
+ allow(view).to receive(:current_user).and_return(user)
+ allow(view).to receive(:user_session).and_return(user_session)
+ end
+
+ it 'renders a list of platform authenticators' do
+ expect(rendered).to have_selector('[role="list"] [role="list-item"]', count: 2)
+ end
+end