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"