diff --git a/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb
new file mode 100644
index 00000000000..f108c15b62a
--- /dev/null
+++ b/app/controllers/api/internal/two_factor_authentication/auth_app_controller.rb
@@ -0,0 +1,56 @@
+module Api
+ module Internal
+ module TwoFactorAuthentication
+ class AuthAppController < ApplicationController
+ include CsrfTokenConcern
+ include ReauthenticationRequiredConcern
+
+ before_action :render_unauthorized, unless: :recently_authenticated_2fa?
+
+ after_action :add_csrf_token_header_to_response
+
+ respond_to :json
+
+ def update
+ result = ::TwoFactorAuthentication::AuthAppUpdateForm.new(
+ user: current_user,
+ configuration_id: params[:id],
+ ).submit(name: params[:name])
+
+ analytics.auth_app_update_name_submitted(**result.to_h)
+
+ if result.success?
+ render json: { success: true }
+ else
+ render json: { success: false, error: result.first_error_message }, status: :bad_request
+ end
+ end
+
+ def destroy
+ result = ::TwoFactorAuthentication::AuthAppDeleteForm.new(
+ user: current_user,
+ configuration_id: params[:id],
+ ).submit
+
+ analytics.auth_app_delete_submitted(**result.to_h)
+
+ if result.success?
+ create_user_event(:authenticator_disabled)
+ revoke_remember_device(current_user)
+ event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user)
+ PushNotification::HttpPush.deliver(event)
+ render json: { success: true }
+ else
+ render json: { success: false, error: result.first_error_message }, status: :bad_request
+ end
+ end
+
+ private
+
+ def render_unauthorized
+ render json: { error: 'Unauthorized' }, status: :unauthorized
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/users/auth_app_controller.rb b/app/controllers/users/auth_app_controller.rb
new file mode 100644
index 00000000000..2082362dc04
--- /dev/null
+++ b/app/controllers/users/auth_app_controller.rb
@@ -0,0 +1,65 @@
+module Users
+ class AuthAppController < ApplicationController
+ include ReauthenticationRequiredConcern
+
+ before_action :confirm_two_factor_authenticated
+ before_action :confirm_recently_authenticated_2fa
+ before_action :set_form
+ before_action :validate_configuration_exists
+
+ def edit; end
+
+ def update
+ result = form.submit(name: params.dig(:form, :name))
+
+ analytics.auth_app_update_name_submitted(**result.to_h)
+
+ if result.success?
+ flash[:success] = t('two_factor_authentication.auth_app.renamed')
+ redirect_to account_path
+ else
+ flash.now[:error] = result.first_error_message
+ render :edit
+ end
+ end
+
+ def destroy
+ result = form.submit
+
+ analytics.auth_app_delete_submitted(**result.to_h)
+
+ if result.success?
+ flash[:success] = t('two_factor_authentication.auth_app.deleted')
+ create_user_event(:authenticator_disabled)
+ revoke_remember_device(current_user)
+ event = PushNotification::RecoveryInformationChangedEvent.new(user: current_user)
+ PushNotification::HttpPush.deliver(event)
+ redirect_to account_path
+ else
+ flash[:error] = result.first_error_message
+ redirect_to edit_auth_app_path(id: params[:id])
+ end
+ end
+
+ private
+
+ def form
+ @form ||= form_class.new(user: current_user, configuration_id: params[:id])
+ end
+
+ alias_method :set_form, :form
+
+ def form_class
+ case action_name
+ when 'edit', 'update'
+ TwoFactorAuthentication::AuthAppUpdateForm
+ when 'destroy'
+ TwoFactorAuthentication::AuthAppDeleteForm
+ end
+ end
+
+ def validate_configuration_exists
+ render_not_found if form.configuration.blank?
+ end
+ end
+end
diff --git a/app/forms/two_factor_authentication/auth_app_delete_form.rb b/app/forms/two_factor_authentication/auth_app_delete_form.rb
new file mode 100644
index 00000000000..6a49f4513e4
--- /dev/null
+++ b/app/forms/two_factor_authentication/auth_app_delete_form.rb
@@ -0,0 +1,57 @@
+module TwoFactorAuthentication
+ class AuthAppDeleteForm
+ include ActiveModel::Model
+ include ActionView::Helpers::TranslationHelper
+
+ attr_reader :user, :configuration_id
+
+ validate :validate_configuration_exists
+ validate :validate_has_multiple_mfa
+
+ def initialize(user:, configuration_id:)
+ @user = user
+ @configuration_id = configuration_id
+ end
+
+ def submit
+ success = valid?
+
+ configuration.destroy if success
+
+ FormResponse.new(
+ success:,
+ errors:,
+ extra: extra_analytics_attributes,
+ serialize_error_details_only: true,
+ )
+ end
+
+ def configuration
+ @configuration ||= user.auth_app_configurations.find_by(id: configuration_id)
+ end
+
+ private
+
+ def validate_configuration_exists
+ return if configuration.present?
+ errors.add(
+ :configuration_id,
+ :configuration_not_found,
+ message: t('errors.manage_authenticator.internal_error'),
+ )
+ end
+
+ def validate_has_multiple_mfa
+ return if !configuration || MfaPolicy.new(user).multiple_factors_enabled?
+ errors.add(
+ :configuration_id,
+ :only_method,
+ message: t('errors.manage_authenticator.remove_only_method_error'),
+ )
+ end
+
+ def extra_analytics_attributes
+ { configuration_id: }
+ end
+ end
+end
diff --git a/app/forms/two_factor_authentication/auth_app_update_form.rb b/app/forms/two_factor_authentication/auth_app_update_form.rb
new file mode 100644
index 00000000000..cd6bb2dbcbb
--- /dev/null
+++ b/app/forms/two_factor_authentication/auth_app_update_form.rb
@@ -0,0 +1,68 @@
+module TwoFactorAuthentication
+ class AuthAppUpdateForm
+ include ActiveModel::Model
+ include ActionView::Helpers::TranslationHelper
+
+ attr_reader :user, :configuration_id
+
+ validate :validate_configuration_exists
+ validate :validate_unique_name
+
+ def initialize(user:, configuration_id:)
+ @user = user
+ @configuration_id = configuration_id
+ end
+
+ def submit(name:)
+ @name = name
+
+ success = valid?
+ if valid?
+ configuration.name = name
+ success = configuration.valid?
+ errors.merge!(configuration.errors)
+ configuration.save if success
+ end
+
+ FormResponse.new(
+ success:,
+ errors:,
+ extra: extra_analytics_attributes,
+ serialize_error_details_only: true,
+ )
+ end
+
+ def name
+ return @name if defined?(@name)
+ @name = configuration&.name
+ end
+
+ def configuration
+ @configuration ||= user.auth_app_configurations.find_by(id: configuration_id)
+ end
+
+ private
+
+ def validate_configuration_exists
+ return if configuration.present?
+ errors.add(
+ :configuration_id,
+ :configuration_not_found,
+ message: t('errors.manage_authenticator.internal_error'),
+ )
+ end
+
+ def validate_unique_name
+ return unless user.auth_app_configurations.where.not(id: configuration_id).find_by(name:)
+ errors.add(
+ :name,
+ :duplicate,
+ message: t('errors.manage_authenticator.unique_name_error'),
+ )
+ end
+
+ def extra_analytics_attributes
+ { configuration_id: }
+ end
+ end
+end
diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb
index f1956723085..0fbe31c784b 100644
--- a/app/services/analytics_events.rb
+++ b/app/services/analytics_events.rb
@@ -188,6 +188,45 @@ def add_phone_setup_visit
)
end
+ # Tracks when a user deletes their auth app from account
+ # @param [Boolean] success
+ # @param [Hash] error_details
+ # @param [Integer] configuration_id
+ def auth_app_delete_submitted(
+ success:,
+ configuration_id:,
+ error_details: nil,
+ **extra
+ )
+ track_event(
+ :auth_app_delete_submitted,
+ success:,
+ error_details:,
+ configuration_id:,
+ **extra,
+ )
+ end
+
+ # When a user updates name for auth app
+ # @param [Boolean] success
+ # @param [Hash] error_details
+ # @param [Integer] configuration_id
+ # Tracks when user submits a name change for an Auth App configuration
+ def auth_app_update_name_submitted(
+ success:,
+ configuration_id:,
+ error_details: nil,
+ **extra
+ )
+ track_event(
+ :auth_app_update_name_submitted,
+ success:,
+ error_details:,
+ configuration_id:,
+ **extra,
+ )
+ end
+
# When a user views the "you are already signed in with the following email" screen
def authentication_confirmation
track_event('Authentication Confirmation')
diff --git a/app/views/accounts/_auth_apps.html.erb b/app/views/accounts/_auth_apps.html.erb
index 64a39eaf59f..e13de333a53 100644
--- a/app/views/accounts/_auth_apps.html.erb
+++ b/app/views/accounts/_auth_apps.html.erb
@@ -1,22 +1,24 @@
- <% MfaContext.new(current_user).auth_app_configurations.each do |auth_app_configuration| %>
-
-
-
- <%= auth_app_configuration.name %>
-
-
- <% if MfaPolicy.new(current_user).multiple_factors_enabled? %>
-
- <%= render 'accounts/actions/disable_totp', id: auth_app_configuration.id %>
-
- <% end %>
-
+
+
+ <% MfaContext.new(current_user).auth_app_configurations.each do |configuration| %>
+ <%= render ManageableAuthenticatorComponent.new(
+ configuration:,
+ user_session:,
+ manage_url: edit_auth_app_path(id: configuration.id),
+ manage_api_url: api_internal_two_factor_authentication_auth_app_path(id: configuration.id),
+ custom_strings: {
+ deleted: t('two_factor_authentication.auth_app.deleted'),
+ renamed: t('two_factor_authentication.auth_app.renamed'),
+ manage_accessible_label: t('two_factor_authentication.auth_app.manage_accessible_label'),
+ },
+ role: 'list-item',
+ ) %>
<% end %>
+
<% if current_user.auth_app_configurations.count < IdentityConfig.store.max_auth_apps_per_account %>
<%= render ButtonComponent.new(
action: ->(**tag_options, &block) do
diff --git a/app/views/users/auth_app/edit.html.erb b/app/views/users/auth_app/edit.html.erb
new file mode 100644
index 00000000000..55bbe2409a5
--- /dev/null
+++ b/app/views/users/auth_app/edit.html.erb
@@ -0,0 +1,40 @@
+<% self.title = t('two_factor_authentication.auth_app.edit_heading') %>
+
+<%= render PageHeadingComponent.new.with_content(t('two_factor_authentication.auth_app.edit_heading')) %>
+
+<%= simple_form_for(
+ @form,
+ as: :form,
+ method: :put,
+ html: { autocomplete: 'off' },
+ url: auth_app_path(id: @form.configuration.id),
+ ) do |f| %>
+ <%= render ValidatedFieldComponent.new(
+ form: f,
+ name: :name,
+ label: t('two_factor_authentication.auth_app.nickname'),
+ ) %>
+
+ <%= f.submit(
+ t('two_factor_authentication.auth_app.change_nickname'),
+ class: 'display-block margin-top-5',
+ ) %>
+<% end %>
+
+<%= render ButtonComponent.new(
+ action: ->(**tag_options, &block) do
+ button_to(
+ auth_app_path(id: @form.configuration.id),
+ form: { aria: { label: t('two_factor_authentication.auth_app.delete') } },
+ **tag_options,
+ &block
+ )
+ end,
+ method: :delete,
+ big: true,
+ wide: true,
+ danger: true,
+ class: 'display-block margin-top-2',
+ ).with_content(t('two_factor_authentication.auth_app.delete')) %>
+
+<%= render 'shared/cancel', link: account_path %>
diff --git a/config/locales/two_factor_authentication/en.yml b/config/locales/two_factor_authentication/en.yml
index 0dd6bd01859..e55ae0021a5 100644
--- a/config/locales/two_factor_authentication/en.yml
+++ b/config/locales/two_factor_authentication/en.yml
@@ -20,6 +20,14 @@ en:
attempt_remaining_warning_html:
one: You have
%{count} attempt remaining.
other: You have
%{count} attempts remaining.
+ auth_app:
+ change_nickname: Change nickname
+ delete: Delete this device
+ deleted: Successfully deleted an authentication app method
+ edit_heading: Manage your authentication app settings
+ manage_accessible_label: Manage authentication app
+ nickname: Nickname
+ renamed: Successfully renamed your authentication app method
backup_code_header_text: Enter your backup code
backup_code_prompt: You can use this backup code once. After you submit it,
you’ll need to use a new backup code next time.
diff --git a/config/locales/two_factor_authentication/es.yml b/config/locales/two_factor_authentication/es.yml
index 01ef2bc2814..5f094a6efae 100644
--- a/config/locales/two_factor_authentication/es.yml
+++ b/config/locales/two_factor_authentication/es.yml
@@ -20,6 +20,15 @@ es:
attempt_remaining_warning_html:
one: Le quedan
%{count} intento.
other: Le quedan
%{count} intentos.
+ auth_app:
+ change_nickname: Cambiar apodo
+ delete: Eliminar este dispositivo
+ deleted: Se ha eliminado correctamente un método de aplicación de autenticación.
+ edit_heading: Gestionar la configuración de su aplicación de autenticación
+ manage_accessible_label: Gestionar la aplicación de autenticación
+ nickname: Apodo
+ renamed: Se ha cambiado correctamente el nombre de su método de aplicación de
+ autenticación.
backup_code_header_text: Ingrese su código de respaldo
backup_code_prompt: Puede utilizar este código de respaldo una vez. Tendrá que
usar un nuevo código de respaldo la próxima vez después de que lo envíe.
diff --git a/config/locales/two_factor_authentication/fr.yml b/config/locales/two_factor_authentication/fr.yml
index bca8d4bb545..01465df3f19 100644
--- a/config/locales/two_factor_authentication/fr.yml
+++ b/config/locales/two_factor_authentication/fr.yml
@@ -22,6 +22,14 @@ fr:
attempt_remaining_warning_html:
one: Il vous reste
%{count} tentative.
other: Il vous reste
%{count} tentatives.
+ auth_app:
+ change_nickname: Changer de pseudo
+ delete: Supprimer cet appareil
+ deleted: Suppression réussie d’une méthode d’application d’authentification
+ edit_heading: Gérer les paramètres de votre application d’authentification
+ manage_accessible_label: Gérer l’application d’authentification
+ nickname: Pseudo
+ renamed: Votre méthode d’application d’authentification a été renommée avec succès
backup_code_header_text: Entrez votre code de sauvegarde
backup_code_prompt: Vous pouvez utiliser ce code de sauvegarde une seule fois.
Après l’avoir envoyé, vous devrez utiliser un nouveau code de sauvegarde
diff --git a/config/routes.rb b/config/routes.rb
index 78ab0a170d6..1968d04dc18 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -23,6 +23,8 @@
namespace :two_factor_authentication do
put '/webauthn/:id' => 'webauthn#update', as: :webauthn
delete '/webauthn/:id' => 'webauthn#destroy', as: nil
+ put '/auth_app/:id' => 'auth_app#update', as: :auth_app
+ delete '/auth_app/:id' => 'auth_app#destroy', as: nil
end
end
end
@@ -255,7 +257,9 @@
get '/manage/webauthn/:id' => 'users/webauthn#edit', as: :edit_webauthn
put '/manage/webauthn/:id' => 'users/webauthn#update', as: :webauthn
delete '/manage/webauthn/:id' => 'users/webauthn#destroy', as: nil
-
+ get '/manage/auth_app/:id' => 'users/auth_app#edit', as: :edit_auth_app
+ put '/manage/auth_app/:id' => 'users/auth_app#update', as: :auth_app
+ delete '/manage/auth_app/:id' => 'users/auth_app#destroy', as: nil
get '/account/personal_key' => 'accounts/personal_keys#new', as: :create_new_personal_key
post '/account/personal_key' => 'accounts/personal_keys#create'
diff --git a/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb b/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb
new file mode 100644
index 00000000000..199a0e82a53
--- /dev/null
+++ b/spec/controllers/api/internal/two_factor_authentication/auth_app_controller_spec.rb
@@ -0,0 +1,217 @@
+require 'rails_helper'
+
+RSpec.describe Api::Internal::TwoFactorAuthentication::AuthAppController do
+ let(:user) { create(:user, :with_phone) }
+ let(:configuration) { create(:auth_app_configuration, user:) }
+
+ before do
+ stub_analytics
+ stub_sign_in(user) if user
+ end
+
+ describe '#update' do
+ let(:name) { 'example' }
+ let(:params) { { id: configuration.id, name: } }
+ let(:response) { put :update, params: params }
+ subject(:response_body) { JSON.parse(response.body, symbolize_names: true) }
+
+ it 'responds with successful result' do
+ expect(response_body).to eq(success: true)
+ expect(response.status).to eq(200)
+ end
+
+ it 'logs the submission attempt' do
+ response
+
+ expect(@analytics).to have_logged_event(
+ :auth_app_update_name_submitted,
+ success: true,
+ error_details: nil,
+ configuration_id: configuration.id.to_s,
+ )
+ end
+
+ it 'includes csrf token in the response headers' do
+ expect(response.headers['X-CSRF-Token']).to be_kind_of(String)
+ end
+
+ context 'signed out' do
+ let(:user) { nil }
+ let(:configuration) { create(:auth_app_configuration) }
+
+ it 'responds with unauthorized response' do
+ expect(response_body).to eq(error: 'Unauthorized')
+ expect(response.status).to eq(401)
+ end
+ end
+
+ context 'with invalid submission' do
+ let(:name) { '' }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(success: false, error: t('errors.messages.blank'))
+ expect(response.status).to eq(400)
+ end
+
+ it 'logs the submission attempt' do
+ response
+
+ expect(@analytics).to have_logged_event(
+ :auth_app_update_name_submitted,
+ success: false,
+ configuration_id: configuration.id.to_s,
+ error_details: { name: { blank: true } },
+ )
+ end
+ end
+
+ context 'not recently authenticated' do
+ before do
+ allow(controller).to receive(:recently_authenticated_2fa?).and_return(false)
+ end
+
+ it 'responds with unauthorized response' do
+ expect(response_body).to eq(error: 'Unauthorized')
+ expect(response.status).to eq(401)
+ end
+ end
+
+ context 'with a configuration that does not exist' do
+ let(:params) { { id: 0 } }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(
+ success: false,
+ error: t('errors.manage_authenticator.internal_error'),
+ )
+ expect(response.status).to eq(400)
+ end
+ end
+
+ context 'with a configuration that does not belong to the user' do
+ let(:configuration) { create(:auth_app_configuration) }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(
+ success: false,
+ error: t('errors.manage_authenticator.internal_error'),
+ )
+ expect(response.status).to eq(400)
+ end
+ end
+ end
+
+ describe '#destroy' do
+ let(:params) { { id: configuration.id } }
+ let(:response) { delete :destroy, params: params }
+ subject(:response_body) { JSON.parse(response.body, symbolize_names: true) }
+
+ it 'responds with successful result' do
+ expect(response_body).to eq(success: true)
+ expect(response.status).to eq(200)
+ end
+
+ it 'logs the submission attempt' do
+ response
+
+ expect(@analytics).to have_logged_event(
+ :auth_app_delete_submitted,
+ success: true,
+ configuration_id: configuration.id.to_s,
+ error_details: nil,
+ )
+ end
+
+ it 'includes csrf token in the response headers' do
+ expect(response.headers['X-CSRF-Token']).to be_kind_of(String)
+ end
+
+ it 'sends a recovery information changed event' do
+ expect(PushNotification::HttpPush).to receive(:deliver).
+ with(PushNotification::RecoveryInformationChangedEvent.new(user: user))
+
+ response
+ end
+
+ it 'revokes remembered device' do
+ expect(user.remember_device_revoked_at).to eq nil
+
+ freeze_time do
+ response
+ expect(user.reload.remember_device_revoked_at).to eq Time.zone.now
+ end
+ end
+
+ it 'logs a user event for the removed credential' do
+ expect { response }.to change { user.events.authenticator_disabled.size }.by 1
+ end
+
+ context 'signed out' do
+ let(:user) { nil }
+ let(:configuration) { create(:auth_app_configuration) }
+
+ it 'responds with unauthorized response' do
+ expect(response_body).to eq(error: 'Unauthorized')
+ expect(response.status).to eq(401)
+ end
+ end
+
+ context 'with invalid submission' do
+ let(:user) { create(:user) }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(
+ success: false,
+ error: t('errors.manage_authenticator.remove_only_method_error'),
+ )
+ expect(response.status).to eq(400)
+ end
+
+ it 'logs the submission attempt' do
+ response
+
+ expect(@analytics).to have_logged_event(
+ :auth_app_delete_submitted,
+ success: false,
+ configuration_id: configuration.id.to_s,
+ error_details: { configuration_id: { only_method: true } },
+ )
+ end
+ end
+
+ context 'not recently authenticated' do
+ before do
+ allow(controller).to receive(:recently_authenticated_2fa?).and_return(false)
+ end
+
+ it 'responds with unauthorized response' do
+ expect(response_body).to eq(error: 'Unauthorized')
+ expect(response.status).to eq(401)
+ end
+ end
+
+ context 'with a configuration that does not exist' do
+ let(:params) { { id: 0 } }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(
+ success: false,
+ error: t('errors.manage_authenticator.internal_error'),
+ )
+ expect(response.status).to eq(400)
+ end
+ end
+
+ context 'with a configuration that does not belong to the user' do
+ let(:configuration) { create(:auth_app_configuration) }
+
+ it 'responds with unsuccessful result' do
+ expect(response_body).to eq(
+ success: false,
+ error: t('errors.manage_authenticator.internal_error'),
+ )
+ expect(response.status).to eq(400)
+ end
+ end
+ end
+end
diff --git a/spec/features/remember_device/totp_spec.rb b/spec/features/remember_device/totp_spec.rb
index 207b6fd6d62..f3de6d554bc 100644
--- a/spec/features/remember_device/totp_spec.rb
+++ b/spec/features/remember_device/totp_spec.rb
@@ -41,17 +41,29 @@ def remember_device_and_sign_out_user
context 'update totp' do
def remember_device_and_sign_out_user
+ auth_app_config = create(:auth_app_configuration, user:)
+ name = auth_app_config.name
+
sign_in_and_2fa_user(user)
visit account_two_factor_authentication_path
- page.find('.remove-auth-app').click # Delete
- click_on t('account.index.totp_confirm_delete')
+
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.auth_app.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ click_button t('two_factor_authentication.auth_app.delete')
+
travel_to(10.seconds.from_now) # Travel past the revoked at date from disabling the device
click_link t('account.index.auth_app_add'), href: authenticator_setup_url
fill_in_totp_name
fill_in :code, with: totp_secret_from_page
check t('forms.messages.remember_device')
click_submit_default
- expect(page).to have_current_path(account_two_factor_authentication_path)
+ expect(page).to have_current_path(account_path)
first(:button, t('links.sign_out')).click
user
end
diff --git a/spec/features/users/totp_management_spec.rb b/spec/features/users/totp_management_spec.rb
index de496c4e0c5..49b43395fb2 100644
--- a/spec/features/users/totp_management_spec.rb
+++ b/spec/features/users/totp_management_spec.rb
@@ -4,18 +4,95 @@
context 'when the user has totp enabled' do
let(:user) { create(:user, :fully_registered, :with_authentication_app) }
- it 'allows the user to disable their totp app' do
+ it 'allows user to delete a platform authenticator when another 2FA option is set up' do
+ auth_app_config = create(:auth_app_configuration, user:)
+ name = auth_app_config.name
+
+ sign_in_and_2fa_user(user)
+ visit account_two_factor_authentication_path
+
+ expect(user.reload.auth_app_configurations.count).to eq(2)
+ expect(page).to have_content(name)
+
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.auth_app.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_auth_app_path(id: auth_app_config.id))
+
+ click_button t('two_factor_authentication.auth_app.delete')
+
+ expect(page).to have_content(t('two_factor_authentication.auth_app.deleted'))
+ expect(user.reload.auth_app_configurations.count).to eq(1)
+ end
+
+ it 'allows user to rename an authentication app app' do
+ auth_app_configuration = create(:auth_app_configuration, user:)
+ name = auth_app_configuration.name
+
sign_in_and_2fa_user(user)
visit account_two_factor_authentication_path
- expect(page).to have_content(t('two_factor_authentication.login_options.auth_app'))
- expect(page.find('.remove-auth-app')).to_not be_nil
- page.find('.remove-auth-app').click
+ expect(page).to have_content(name)
+
+ click_link(
+ format(
+ '%s: %s',
+ t('two_factor_authentication.auth_app.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_auth_app_path(id: auth_app_configuration.id))
+ expect(page).to have_field(
+ t('two_factor_authentication.auth_app.nickname'),
+ with: name,
+ )
+
+ fill_in t('two_factor_authentication.auth_app.nickname'), with: 'new name'
+
+ click_button t('two_factor_authentication.auth_app.change_nickname')
+
+ expect(page).to have_content('new name')
+ expect(page).to have_content(t('two_factor_authentication.auth_app.renamed'))
+ end
+
+ it 'requires a user to use a unique name when renaming' do
+ existing_auth_app_configuration = create(:auth_app_configuration, user:, name: 'existing')
+ new_app_auth_configuration = create(:auth_app_configuration, user:, name: 'new existing')
+ name = existing_auth_app_configuration.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.auth_app.manage_accessible_label'),
+ name,
+ ),
+ )
+
+ expect(current_path).to eq(edit_auth_app_path(id: existing_auth_app_configuration.id))
+ expect(page).to have_field(
+ t('two_factor_authentication.auth_app.nickname'),
+ with: name,
+ )
+
+ fill_in t('two_factor_authentication.auth_app.nickname'),
+ with: new_app_auth_configuration.name
+
+ click_button t('two_factor_authentication.auth_app.change_nickname')
- expect(current_path).to eq auth_app_delete_path
- click_on t('account.index.totp_confirm_delete')
+ expect(current_path).to eq(edit_auth_app_path(id: existing_auth_app_configuration.id))
- expect(current_path).to eq account_two_factor_authentication_path
+ expect(page).to have_content(t('errors.manage_authenticator.unique_name_error'))
end
end
diff --git a/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb b/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb
new file mode 100644
index 00000000000..3e8c3f15dcb
--- /dev/null
+++ b/spec/forms/two_factor_authentication/auth_app_delete_form_spec.rb
@@ -0,0 +1,89 @@
+require 'rails_helper'
+
+RSpec.describe TwoFactorAuthentication::AuthAppDeleteForm do
+ let(:user) { create(:user) }
+ let(:configuration) { create(:auth_app_configuration, user:) }
+ let(:configuration_id) { configuration&.id }
+ let(:form) { described_class.new(user:, configuration_id:) }
+
+ describe '#submit' do
+ let(:result) { form.submit }
+
+ context 'with having another mfa enabled' do
+ let(:user) { create(:user, :with_phone) }
+
+ it 'returns a successful result' do
+ expect(result.success?).to eq(true)
+ expect(result.to_h).to eq(success: true, configuration_id:)
+ end
+
+ context 'with blank configuration' do
+ let(:configuration) { nil }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+
+ context 'with configuration that does not exist' do
+ let(:configuration_id) { 'does-not-exist' }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+
+ context 'with configuration not belonging to the user' do
+ let(:configuration) { create(:auth_app_configuration) }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+ end
+
+ context 'with user not having another mfa enabled' do
+ let(:user) { create(:user) }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { only_method: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+ end
+
+ describe '#configuration' do
+ subject(:form_configuration) { form.configuration }
+
+ it 'returns configuration' do
+ expect(form_configuration).to eq(configuration)
+ end
+ end
+end
diff --git a/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb b/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb
new file mode 100644
index 00000000000..87d1dd46948
--- /dev/null
+++ b/spec/forms/two_factor_authentication/auth_app_update_form_spec.rb
@@ -0,0 +1,142 @@
+require 'rails_helper'
+
+RSpec.describe TwoFactorAuthentication::AuthAppUpdateForm do
+ let(:user) { create(:user) }
+ let(:original_name) { 'original-name' }
+ let(:configuration) { create(:auth_app_configuration, user:, name: original_name) }
+ let(:configuration_id) { configuration&.id }
+ let(:form) { described_class.new(user:, configuration_id:) }
+
+ describe '#submit' do
+ let(:name) { 'new-namae' }
+ let(:result) { form.submit(name:) }
+
+ it 'returns a successful result' do
+ expect(result.success?).to eq(true)
+ expect(result.to_h).to eq(success: true, configuration_id:)
+ end
+
+ it 'saves the new name' do
+ result
+
+ expect(configuration.reload.name).to eq(name)
+ end
+
+ context 'with blank configuration' do
+ let(:configuration) { nil }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+
+ context 'with configuration that does not exist' do
+ let(:configuration_id) { 'does-not-exist' }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+ end
+
+ context 'with configuration not belonging to the user' do
+ let(:configuration) { create(:auth_app_configuration, name: original_name) }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ configuration_id: { configuration_not_found: true },
+ },
+ configuration_id:,
+ )
+ end
+
+ it 'does not save the new name' do
+ expect(configuration).not_to receive(:save)
+
+ result
+
+ expect(configuration.reload.name).to eq(original_name)
+ end
+ end
+
+ context 'with blank name' do
+ let(:name) { '' }
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ name: { blank: true },
+ },
+ configuration_id:,
+ )
+ end
+
+ it 'does not save the new name' do
+ expect(configuration).not_to receive(:save)
+
+ result
+
+ expect(configuration.reload.name).to eq(original_name)
+ end
+ end
+
+ context 'with duplicate name' do
+ before do
+ create(:auth_app_configuration, user:, name:)
+ end
+
+ it 'returns an unsuccessful result' do
+ expect(result.success?).to eq(false)
+ expect(result.to_h).to eq(
+ success: false,
+ error_details: {
+ name: { duplicate: true },
+ },
+ configuration_id:,
+ )
+ end
+
+ it 'does not save the new name' do
+ expect(configuration).not_to receive(:save)
+
+ result
+
+ expect(configuration.reload.name).to eq(original_name)
+ end
+ end
+ end
+
+ describe '#name' do
+ subject(:name) { form.name }
+
+ it 'returns configuration name' do
+ expect(name).to eq(configuration.name)
+ end
+ end
+
+ describe '#configuration' do
+ subject(:form_configuration) { form.configuration }
+
+ it 'returns configuration' do
+ expect(form_configuration).to eq(configuration)
+ end
+ end
+end
diff --git a/spec/views/accounts/_auth_apps.html.erb_spec.rb b/spec/views/accounts/_auth_apps.html.erb_spec.rb
new file mode 100644
index 00000000000..3160151842f
--- /dev/null
+++ b/spec/views/accounts/_auth_apps.html.erb_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+RSpec.describe 'accounts/_auth_apps.html.erb' do
+ let(:user) do
+ create(
+ :user,
+ auth_app_configurations: create_list(:auth_app_configuration, 2),
+ )
+ end
+ let(:user_session) { { auth_events: [] } }
+
+ subject(:rendered) { render partial: 'accounts/auth_apps' }
+
+ 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 auth apps' do
+ expect(rendered).to have_selector('[role="list"] [role="list-item"]', count: 2)
+ end
+end
diff --git a/spec/views/accounts/show.html.erb_spec.rb b/spec/views/accounts/show.html.erb_spec.rb
index 23cf81fb0fb..22e8364a511 100644
--- a/spec/views/accounts/show.html.erb_spec.rb
+++ b/spec/views/accounts/show.html.erb_spec.rb
@@ -109,25 +109,6 @@
end
end
- context 'auth app listing and adding' do
- context 'user has no auth app' do
- let(:user) { create(:user, :fully_registered, :with_piv_or_cac) }
-
- it 'does not render auth app' do
- expect(view).to_not render_template(partial: '_auth_apps')
- end
- end
-
- context 'user has an auth app' do
- let(:user) { create(:user, :fully_registered, :with_authentication_app) }
- it 'renders the auth app section' do
- render
-
- expect(view).to render_template(partial: '_auth_apps')
- end
- end
- end
-
context 'PIV/CAC listing and adding' do
context 'user has no piv/cac' do
let(:user) { create(:user, :fully_registered, :with_authentication_app) }
diff --git a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb
index 66441dfbd63..66baa50c0e0 100644
--- a/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb
+++ b/spec/views/accounts/two_factor_authentication/show.html.erb_spec.rb
@@ -37,15 +37,6 @@
),
)
end
-
- it 'contains link to disable TOTP' do
- render
-
- expect(rendered).to have_link(
- t('forms.buttons.disable'),
- href: auth_app_delete_path(id: user.auth_app_configurations.first.id),
- )
- end
end
context 'when the user does not have password_reset_profile' do
diff --git a/spec/views/users/auth_app/edit.html.erb_spec.rb b/spec/views/users/auth_app/edit.html.erb_spec.rb
new file mode 100644
index 00000000000..55e90c0393f
--- /dev/null
+++ b/spec/views/users/auth_app/edit.html.erb_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe 'users/auth_app/edit.html.erb' do
+ include Devise::Test::ControllerHelpers
+
+ let(:nickname) { 'Example' }
+ let(:configuration) { create(:auth_app_configuration, name: nickname) }
+ let(:user) { create(:user, auth_app_configurations: [configuration]) }
+ let(:form) do
+ TwoFactorAuthentication::AuthAppUpdateForm.new(
+ user:,
+ configuration_id: configuration.id,
+ )
+ end
+
+ subject(:rendered) { render }
+
+ before do
+ @form = form
+ end
+
+ it 'renders form to update configuration' do
+ expect(rendered).to have_selector(
+ "form[action='#{auth_app_path(id: configuration.id)}'] input[name='_method'][value='put']",
+ visible: false,
+ )
+ end
+
+ it 'initializes form with configuration values' do
+ expect(rendered).to have_field(
+ t('two_factor_authentication.auth_app.nickname'),
+ with: nickname,
+ )
+ end
+
+ it 'has labelled form with button to delete configuration' do
+ expect(rendered).to have_button_to_with_accessibility(
+ t('two_factor_authentication.auth_app.delete'),
+ auth_app_path(id: configuration.id),
+ )
+ end
+end