diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb
new file mode 100644
index 000000000..50dd7cff7
--- /dev/null
+++ b/app/controllers/admin/invitations_controller.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Admin
+ class InvitationsController < Admin::BaseController
+ def destroy
+ invitation = Invitation.find(params[:id])
+ invitation.destroy!
+ redirect_to admin_users_path, notice: t(".success")
+ end
+
+ def destroy_all
+ family = Family.find(params[:id])
+ family.invitations.pending.destroy_all
+ redirect_to admin_users_path, notice: t(".success")
+ end
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index f5a3ae953..a86fda917 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -35,6 +35,10 @@ module Admin
-(@entries_count_by_family[family.id] || 0)
end
+ @invitations_by_family = Invitation.pending
+ .where(family_id: family_ids)
+ .group_by(&:family_id)
+
@trials_expiring_in_7_days = Subscription
.where(status: :trialing)
.where(trial_ends_at: Time.current..7.days.from_now)
diff --git a/app/javascript/controllers/admin_invitation_delete_controller.js b/app/javascript/controllers/admin_invitation_delete_controller.js
new file mode 100644
index 000000000..e819d4200
--- /dev/null
+++ b/app/javascript/controllers/admin_invitation_delete_controller.js
@@ -0,0 +1,22 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="admin-invitation-delete"
+// Handles individual invitation deletion and alt-click to delete all family invitations
+export default class extends Controller {
+ static targets = [ "button", "destroyAllForm" ]
+ static values = { deleteAllLabel: String }
+
+ handleClick(event) {
+ if (event.altKey) {
+ event.preventDefault()
+
+ this.buttonTargets.forEach(btn => {
+ btn.textContent = this.deleteAllLabelValue
+ })
+
+ if (this.hasDestroyAllFormTarget) {
+ this.destroyAllFormTarget.requestSubmit()
+ }
+ }
+ }
+}
diff --git a/app/models/invitation.rb b/app/models/invitation.rb
index e2900c7cf..42bee7e9d 100644
--- a/app/models/invitation.rb
+++ b/app/models/invitation.rb
@@ -13,7 +13,7 @@ class Invitation < ApplicationRecord
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member guest] }
validates :token, presence: true, uniqueness: true
- validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family"
+ validate :no_duplicate_pending_invitation_in_family
validate :inviter_is_admin
validate :no_other_pending_invitation, on: :create
@@ -77,6 +77,21 @@ class Invitation < ApplicationRecord
end
end
+ def no_duplicate_pending_invitation_in_family
+ return if email.blank?
+
+ scope = self.class.pending.where(family_id: family_id)
+ scope = scope.where.not(id: id) if persisted?
+
+ exists = if self.class.encryption_ready?
+ scope.where(email: email).exists?
+ else
+ scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists?
+ end
+
+ errors.add(:email, "has already been invited to this family") if exists
+ end
+
def inviter_is_admin
inviter.admin?
end
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
index 0c21d2060..ac30a0062 100644
--- a/app/views/admin/users/index.html.erb
+++ b/app/views/admin/users/index.html.erb
@@ -50,7 +50,10 @@
<% if @families_with_users.any? %>
<% @families_with_users.each do |family, users| %>
-
+ <% pending_invitations = @invitations_by_family[family.id] || [] %>
+
<%= icon "users", class: "w-5 h-5 text-secondary shrink-0" %>
@@ -133,7 +136,46 @@
<% end %>
+ <% if pending_invitations.any? %>
+
+ <% pending_invitations.each do |invitation| %>
+
+
+
+ <%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %>
+
+ <%= invitation.email %>
+ <%= t(".invitations.pending_label") %>
+
+
+ |
+
+ <%= t(".invitations.expires", date: invitation.expires_at.to_fs(:long)) %>
+ |
+
+ —
+ |
+
+ <%= form_with url: admin_invitation_path(invitation), method: :delete, class: "inline" do |f| %>
+
+ <% end %>
+ |
+
+ <% end %>
+
+ <% end %>
+ <% if pending_invitations.any? %>
+ <%= form_with url: invitations_admin_family_path(family), method: :delete,
+ data: { admin_invitation_delete_target: "destroyAllForm" },
+ class: "hidden" do |f| %>
+ <% end %>
+ <% end %>
<% end %>
diff --git a/config/locales/views/admin/invitations/en.yml b/config/locales/views/admin/invitations/en.yml
new file mode 100644
index 000000000..389566e19
--- /dev/null
+++ b/config/locales/views/admin/invitations/en.yml
@@ -0,0 +1,8 @@
+---
+en:
+ admin:
+ invitations:
+ destroy:
+ success: "Invitation deleted."
+ destroy_all:
+ success: "All invitations for this family have been deleted."
diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml
index b14feb785..c14a243d5 100644
--- a/config/locales/views/admin/users/en.yml
+++ b/config/locales/views/admin/users/en.yml
@@ -43,6 +43,11 @@ en:
member: "Basic user access. Can manage their own accounts, transactions, and settings."
admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts."
super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support."
+ invitations:
+ pending_label: "Invited (pending)"
+ expires: "Expires %{date}"
+ delete: "Delete"
+ delete_all: "Delete All"
update:
success: "User role updated successfully."
failure: "Failed to update user role."
diff --git a/config/routes.rb b/config/routes.rb
index d0b6c8827..3cf33b146 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -505,6 +505,12 @@ Rails.application.routes.draw do
end
end
resources :users, only: [ :index, :update ]
+ resources :invitations, only: [ :destroy ]
+ resources :families, only: [] do
+ member do
+ delete :invitations, to: "invitations#destroy_all"
+ end
+ end
end
# Defines the root path route ("/")
diff --git a/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb
new file mode 100644
index 000000000..d714a4143
--- /dev/null
+++ b/db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb
@@ -0,0 +1,9 @@
+class RemoveUniqueEmailFamilyIndexFromInvitations < ActiveRecord::Migration[7.2]
+ def change
+ remove_index :invitations, [ :email, :family_id ], name: "index_invitations_on_email_and_family_id"
+ add_index :invitations, [ :email, :family_id ],
+ name: "index_invitations_on_email_and_family_id_pending",
+ unique: true,
+ where: "accepted_at IS NULL"
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 9c6837f21..b56fb9f41 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do
+ActiveRecord::Schema[7.2].define(version: 2026_03_14_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -740,7 +740,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_08_113006) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "token_digest"
- t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id", unique: true
+ t.index ["email", "family_id"], name: "index_invitations_on_email_and_family_id_pending", unique: true, where: "(accepted_at IS NULL)"
t.index ["email"], name: "index_invitations_on_email"
t.index ["family_id"], name: "index_invitations_on_family_id"
t.index ["inviter_id"], name: "index_invitations_on_inviter_id"