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 @@
+ <% 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") %>
-
<%= accept_invitation_url(invitation.token) %>
-
-
-
- <% end %>
+
+ <% if self_hosted? %>
+
+
<%= t(".invitation_link") %>
+
<%= accept_invitation_url(invitation.token) %>
+
+
+
+ <% 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 %>