Administer invitations in /admin/users (#1185)

* Add invited users with delete button to admin users page

Shows pending invitations per family below active users in /admin/users/.
Each invitation row has a red Delete button aligned with the role column.
Alt/option-clicking any Delete button changes all invitation button labels
to "Delete All" and destroys all pending invitations for that family.

- Add admin routes: DELETE /admin/invitations/:id and DELETE /admin/families/:id/invitations
- Add Admin::InvitationsController with destroy and destroy_all actions
- Load pending invitations grouped by family in users controller index
- Render invitation rows in a dashed-border tbody below active user rows
- Add admin-invitation-delete Stimulus controller for alt-click behavior
- Add i18n strings for invitation UI and flash messages

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Fix destroy_all using params[:id] from member route

The member route /admin/families/:id/invitations sets params[:id],
not params[:family_id], so Family.find was always receiving nil.

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Fix translation key in destroy_all to match locale

t(".success_all") looked up a nonexistent key; the locale defines
admin.invitations.destroy_all.success, so t(".success") is correct.

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Scope bulk delete to pending invitations and allow re-inviting emails

- destroy_all now uses family.invitations.pending.destroy_all so accepted
  and expired invitation history is preserved
- Replace blanket email uniqueness validation with a custom check scoped
  to pending invitations only, so the same email can be invited again
  after an invitation is deleted or expires

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Drop unconditional unique DB index on invitations(email, family_id)

The model-level uniqueness check was already scoped to pending
invitations, but the blanket unique index on (email, family_id)
still caused ActiveRecord::RecordNotUnique when re-inviting an
email that had any historical invitation record in the same family
(e.g. after an accepted invite or after an account deletion).

Replace it with no DB-level unique constraint — the
no_duplicate_pending_invitation_in_family model validation is the
sole enforcer and correctly scopes uniqueness to pending rows only.

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Replace blanket unique index with partial unique index on pending invitations

Instead of dropping the DB-level uniqueness constraint entirely, replace
the unconditional unique index on (email, family_id) with a partial unique
index scoped to WHERE accepted_at IS NULL. This enforces the invariant at
the DB layer (no two non-accepted invitations for the same email in a
family) while allowing re-invites once a prior invitation has been accepted.

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

* Fix migration version and make remove_index reversible

- Change Migration[8.0] to Migration[7.2] to match the rest of the codebase
- Pass column names to remove_index so Rails can reconstruct the old index on rollback

https://claude.ai/code/session_01F8WaH5TmtdUWwhHnVoQ6Gm

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Juan José Mata
2026-03-14 11:32:33 +01:00
committed by GitHub
parent 50f3a5c030
commit 02af8463f6
10 changed files with 132 additions and 4 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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()
}
}
}
}

View File

@@ -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

View File

@@ -50,7 +50,10 @@
<% if @families_with_users.any? %>
<div class="space-y-4">
<% @families_with_users.each do |family, users| %>
<details class="bg-container-inset rounded-lg overflow-hidden group">
<% pending_invitations = @invitations_by_family[family.id] || [] %>
<details class="bg-container-inset rounded-lg overflow-hidden group"
data-controller="admin-invitation-delete"
data-admin-invitation-delete-delete-all-label-value="<%= t('.invitations.delete_all') %>">
<summary class="flex items-center justify-between gap-4 px-4 py-3 cursor-pointer select-none hover:bg-surface-hover">
<div class="flex items-center gap-3">
<%= icon "users", class: "w-5 h-5 text-secondary shrink-0" %>
@@ -133,7 +136,46 @@
</tr>
<% end %>
</tbody>
<% if pending_invitations.any? %>
<tbody class="divide-y divide-primary border-t border-dashed border-primary">
<% pending_invitations.each do |invitation| %>
<tr class="bg-red-50/30 dark:bg-red-950/20">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %>
<div>
<p class="font-medium text-secondary italic"><%= invitation.email %></p>
<p class="text-xs text-secondary"><%= t(".invitations.pending_label") %></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-sm text-secondary whitespace-nowrap">
<%= t(".invitations.expires", date: invitation.expires_at.to_fs(:long)) %>
</td>
<td class="px-4 py-3 text-sm text-secondary text-right whitespace-nowrap">
</td>
<td class="px-4 py-3 text-right">
<%= form_with url: admin_invitation_path(invitation), method: :delete, class: "inline" do |f| %>
<button type="submit"
data-admin-invitation-delete-target="button"
data-action="click->admin-invitation-delete#handleClick"
class="text-sm text-red-600 hover:text-red-800 border border-red-300 rounded-lg px-2 py-1 hover:bg-red-50 transition-colors">
<%= t(".invitations.delete") %>
</button>
<% end %>
</td>
</tr>
<% end %>
</tbody>
<% end %>
</table>
<% 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 %>
</div>
</details>
<% end %>

View File

@@ -0,0 +1,8 @@
---
en:
admin:
invitations:
destroy:
success: "Invitation deleted."
destroy_all:
success: "All invitations for this family have been deleted."

View File

@@ -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."

View File

@@ -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 ("/")

View File

@@ -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

4
db/schema.rb generated
View File

@@ -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"