From 02af8463f6ee4adfb05ec6cca91d29558720a327 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 14 Mar 2026 11:32:33 +0100 Subject: [PATCH] Administer invitations in `/admin/users` (#1185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Claude --- .../admin/invitations_controller.rb | 17 +++++++ app/controllers/admin/users_controller.rb | 4 ++ .../admin_invitation_delete_controller.js | 22 ++++++++++ app/models/invitation.rb | 17 ++++++- app/views/admin/users/index.html.erb | 44 ++++++++++++++++++- config/locales/views/admin/invitations/en.yml | 8 ++++ config/locales/views/admin/users/en.yml | 5 +++ config/routes.rb | 6 +++ ...que_email_family_index_from_invitations.rb | 9 ++++ db/schema.rb | 4 +- 10 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 app/controllers/admin/invitations_controller.rb create mode 100644 app/javascript/controllers/admin_invitation_delete_controller.js create mode 100644 config/locales/views/admin/invitations/en.yml create mode 100644 db/migrate/20260314120000_remove_unique_email_family_index_from_invitations.rb 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"