diff --git a/app/controllers/invitations_controller.rb b/app/controllers/invitations_controller.rb index 020b4707..ca37435a 100644 --- a/app/controllers/invitations_controller.rb +++ b/app/controllers/invitations_controller.rb @@ -34,6 +34,24 @@ class InvitationsController < ApplicationController end end + def destroy + unless Current.user.admin? + flash[:alert] = t("invitations.destroy.not_authorized") + redirect_to settings_profile_path + return + end + + @invitation = Current.family.invitations.find(params[:id]) + + if @invitation.destroy + flash[:notice] = t("invitations.destroy.success") + else + flash[:alert] = t("invitations.destroy.failure") + end + + redirect_to settings_profile_path + end + private def invitation_params diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 882824ec..443ba16b 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -4,4 +4,28 @@ class Settings::ProfilesController < SettingsController @users = Current.family.users.order(:created_at) @pending_invitations = Current.family.invitations.pending end + + def destroy + unless Current.user.admin? + flash[:alert] = t("settings.profiles.destroy.not_authorized") + redirect_to settings_profile_path + return + end + + @user = Current.family.users.find(params[:user_id]) + + if @user == Current.user + flash[:alert] = t("settings.profiles.destroy.cannot_remove_self") + redirect_to settings_profile_path + return + end + + if @user.destroy + flash[:notice] = t("settings.profiles.destroy.member_removed") + else + flash[:alert] = t("settings.profiles.destroy.member_removal_failed") + end + + redirect_to settings_profile_path + end end diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index c21775d6..5f0d5e10 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -43,6 +43,21 @@

<%= user.role %>

+ <% if Current.user.admin? && user != Current.user %> +
+ <%= button_to settings_profile_path(user_id: user), + method: :delete, + class: "text-red-500 hover:text-red-700", + data: { turbo_confirm: { + title: t(".confirm_remove_member.title"), + body: t(".confirm_remove_member.body", name: user.display_name), + accept: t(".remove_member"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} do %> + <%= lucide_icon "x", class: "w-5 h-5" %> + <% end %> +
+ <% end %> <% end %> <% if @pending_invitations.any? %> @@ -59,25 +74,40 @@ - <% if self_hosted? %> -
-

<%= t(".invitation_link") %>

- - - -
- <% end %> +
+ <% if self_hosted? %> +
+

<%= t(".invitation_link") %>

+ + + +
+ <% end %> + <% if Current.user.admin? %> + <%= button_to invitation_path(invitation), + method: :delete, + class: "text-red-500 hover:text-red-700", + data: { turbo_confirm: { + title: t(".confirm_remove_invitation.title"), + body: t(".confirm_remove_invitation.body", email: invitation.email), + accept: t(".remove_invitation"), + acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2" + }} do %> + <%= lucide_icon "x", class: "w-5 h-5" %> + <% end %> + <% end %> +
<% end %> <% end %> diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index e4ce7417..ad1a5da7 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -3,7 +3,7 @@ if ENV["SENTRY_DSN"].present? config.dsn = ENV["SENTRY_DSN"] config.environment = ENV["RAILS_ENV"] config.breadcrumbs_logger = [ :active_support_logger, :http_logger ] - config.enabled_environments = %w[development production] + config.enabled_environments = %w[production] # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 5ce17ce5..db862831 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -4,6 +4,10 @@ en: create: failure: Could not send invitation success: Invitation sent successfully + destroy: + not_authorized: You are not authorized to manage invitations. + success: Invitation was successfully removed. + failure: There was a problem removing the invitation. new: email_label: Email Address email_placeholder: Enter email address diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index f784db97..5bfa97d4 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -48,11 +48,24 @@ en: theme_title: Theme timezone: Timezone profiles: + destroy: + not_authorized: You are not authorized to remove members. + cannot_remove_self: You cannot remove yourself from the account. + member_removed: Member was successfully removed. + member_removal_failed: There was a problem removing the member. show: confirm_delete: body: Are you sure you want to permanently delete your account? This action is irreversible. title: Delete account? + confirm_remove_member: + title: Remove Member + body: Are you sure you want to remove %{name} from your account? + remove_member: Remove Member + confirm_remove_invitation: + title: Remove Invitation + body: Are you sure you want to remove the invitation for %{email}? + remove_invitation: Remove Invitation danger_zone_title: Danger Zone delete_account: Delete account delete_account_warning: Deleting your account will permanently remove all diff --git a/config/routes.rb b/config/routes.rb index cec4c1fd..d83ac11b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -20,7 +20,7 @@ Rails.application.routes.draw do end namespace :settings do - resource :profile, only: :show + resource :profile, only: [ :show, :destroy ] resource :preferences, only: :show resource :hosting, only: %i[show update] resource :billing, only: :show @@ -142,7 +142,7 @@ Rails.application.routes.draw do resources :exchange_rate_provider_missings, only: :update end - resources :invitations, only: [ :new, :create ] do + resources :invitations, only: [ :new, :create, :destroy ] do get :accept, on: :member end diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index b6b7edbe..e6a596e1 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -2,7 +2,7 @@ require "test_helper" class InvitationsControllerTest < ActionDispatch::IntegrationTest setup do - sign_in @user = users(:family_admin) + sign_in @admin = users(:family_admin) @invitation = invitations(:one) end @@ -25,7 +25,7 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest invitation = Invitation.order(created_at: :desc).first assert_equal "member", invitation.role - assert_equal @user, invitation.inviter + assert_equal @admin, invitation.inviter assert_equal "new@example.com", invitation.email assert_redirected_to settings_profile_path assert_equal I18n.t("invitations.create.success"), flash[:notice] @@ -59,8 +59,8 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest invitation = Invitation.order(created_at: :desc).first assert_equal "admin", invitation.role - assert_equal @user.family, invitation.family - assert_equal @user, invitation.inviter + assert_equal @admin.family, invitation.family + assert_equal @admin, invitation.inviter end test "should handle invalid invitation creation" do @@ -86,4 +86,29 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest get accept_invitation_url("invalid-token") assert_response :not_found end + + test "admin can remove pending invitation" do + assert_difference("Invitation.count", -1) do + delete invitation_url(@invitation) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.destroy.success"), flash[:notice] + end + + test "non-admin cannot remove invitations" do + sign_in users(:family_member) + + assert_no_difference("Invitation.count") do + delete invitation_url(@invitation) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("invitations.destroy.not_authorized"), flash[:alert] + end + + test "should handle invalid invitation removal" do + delete invitation_url(id: "invalid-id") + assert_response :not_found + end end diff --git a/test/controllers/settings/profiles_controller_test.rb b/test/controllers/settings/profiles_controller_test.rb index ff6b36ed..27d62c17 100644 --- a/test/controllers/settings/profiles_controller_test.rb +++ b/test/controllers/settings/profiles_controller_test.rb @@ -2,11 +2,46 @@ require "test_helper" class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest setup do - sign_in @user = users(:family_admin) + @admin = users(:family_admin) + @member = users(:family_member) end - test "get" do - get settings_profile_url + test "should get show" do + sign_in @admin + get settings_profile_path assert_response :success end + + test "admin can remove a family member" do + sign_in @admin + assert_difference("User.count", -1) do + delete settings_profile_path(user_id: @member) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.member_removed"), flash[:notice] + assert_raises(ActiveRecord::RecordNotFound) { User.find(@member.id) } + end + + test "admin cannot remove themselves" do + sign_in @admin + assert_no_difference("User.count") do + delete settings_profile_path(user_id: @admin) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.cannot_remove_self"), flash[:alert] + assert User.find(@admin.id) + end + + test "non-admin cannot remove members" do + sign_in @member + assert_no_difference("User.count") do + delete settings_profile_path(user_id: @admin) + end + + assert_redirected_to settings_profile_path + assert_equal I18n.t("settings.profiles.destroy.not_authorized"), flash[:alert] + assert User.find(@admin.id) + end end diff --git a/test/fixtures/invitations.yml b/test/fixtures/invitations.yml index 12ae9a05..09cd96f7 100644 --- a/test/fixtures/invitations.yml +++ b/test/fixtures/invitations.yml @@ -17,3 +17,13 @@ two: created_at: <%= Time.current %> updated_at: <%= Time.current %> expires_at: <%= 3.days.from_now %> + +other_family: + email: "other@example.com" + token: "valid-token-789" + role: "member" + inviter: empty + family: empty + created_at: <%= Time.current %> + updated_at: <%= Time.current %> + expires_at: <%= 3.days.from_now %>