mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
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:
17
app/controllers/admin/invitations_controller.rb
Normal file
17
app/controllers/admin/invitations_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
8
config/locales/views/admin/invitations/en.yml
Normal file
8
config/locales/views/admin/invitations/en.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
en:
|
||||
admin:
|
||||
invitations:
|
||||
destroy:
|
||||
success: "Invitation deleted."
|
||||
destroy_all:
|
||||
success: "All invitations for this family have been deleted."
|
||||
@@ -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."
|
||||
|
||||
@@ -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 ("/")
|
||||
|
||||
@@ -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
4
db/schema.rb
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user