feat: Add account suspension metadata in Super Admin (#15158)

## Description

Super Admins can now record a category and reason when suspending an
account, review the complete suspension history on the account details
page, and correct the latest suspension metadata without losing its
original timestamp. Suspension events are stored internally on the
account without changing customer-facing account API payloads.

## Closes

-
[CW-7653](https://linear.app/chatwoot/issue/CW-7653/ability-to-add-notes-while-suspending-an-acocunt)
- [Implementation
plan](https://linear.app/chatwoot/document/super-admin-account-suspension-metadata-implementation-plan-e7e4eb79d078)

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update

## What changed

- Require a suspension category and a reason of up to 256 characters
when an active account is suspended.
- Store append-only suspension events in `accounts.internal_attributes`,
while preserving unrelated internal metadata.
- Allow corrections to the latest event for an already suspended account
without changing its timestamp.
- Show the full suspension history, newest first, on the Super Admin
account details page.
- Add visual dividers between top-level sections on the Super Admin
account edit page.
- Keep legacy suspended-account edits and new-account creation behavior
unchanged.

## How to test

1. Open an active account in Super Admin and choose **Suspended**.
2. Confirm the category and reason controls appear, reject incomplete or
invalid values, and enforce the 256-character reason limit.
3. Suspend the account with each supported category and confirm the
event appears on the details page.
4. Reactivate and suspend the account again; confirm prior history is
retained and a new event is added.
5. Edit a suspended account's latest category or reason; confirm its
original timestamp is preserved.
6. Confirm a legacy suspended account without history can still be
edited without supplying suspension metadata.

## Checklist

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sony Mathew
2026-07-24 14:48:14 +05:30
committed by GitHub
parent afa6421809
commit 7c1711170b
12 changed files with 316 additions and 18 deletions

View File

@@ -1,4 +1,6 @@
class SuperAdmin::AccountsController < SuperAdmin::ApplicationController class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
before_action :validate_suspension_metadata, only: :update
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
@@ -35,12 +37,18 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# #
def resource_params def resource_params
permitted_params = super permitted_params = super
permitted_params.extract!(:suspension_category, :suspension_reason)
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits) permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models) permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present? permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
permitted_params permitted_params
end end
def update
apply_suspension_metadata
super
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions # See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information # for more information
@@ -66,6 +74,88 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.') redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
# rubocop:enable Rails/I18nLocaleTexts # rubocop:enable Rails/I18nLocaleTexts
end end
private
def validate_suspension_metadata
return unless suspension_metadata_required?
validate_suspension_category
validate_suspension_reason
return if requested_resource.errors.empty?
requested_resource.assign_attributes(resource_params.except(:manually_managed_features))
render :edit,
locals: { page: Administrate::Page::Form.new(dashboard, requested_resource) },
status: :unprocessable_entity
end
def validate_suspension_category
if suspension_details[:category].blank?
requested_resource.errors.add(:suspension_category, :blank)
elsif Account::SUSPENSION_CATEGORIES.exclude?(suspension_details[:category])
requested_resource.errors.add(:suspension_category, :inclusion)
end
end
def validate_suspension_reason
if suspension_details[:reason].blank?
requested_resource.errors.add(:suspension_reason, :blank)
elsif suspension_details[:reason].length > 256
requested_resource.errors.add(:suspension_reason, :too_long, count: 256)
end
end
def suspension_metadata_required?
return false unless target_status == 'suspended'
requested_resource.active? ||
requested_resource.suspension_history.present? ||
suspension_details.values.any?(&:present?)
end
def apply_suspension_metadata
return unless target_status == 'suspended'
history = suspension_history_with_changes
return if history.blank?
requested_resource.internal_attributes = requested_resource.internal_attributes.merge('suspensions' => history)
end
def suspension_history_with_changes
history = requested_resource.suspension_history.map(&:dup)
return history << new_suspension_event if requested_resource.active?
return append_legacy_suspension(history) if history.empty?
return unless suspension_metadata_changed?(history.last)
history.tap { |events| events[-1] = events.last.merge(suspension_details.stringify_keys) }
end
def append_legacy_suspension(history)
return if suspension_details.values.none?(&:present?)
history << new_suspension_event
end
def new_suspension_event
suspension_details.stringify_keys.merge('suspended_at' => Time.current.iso8601)
end
def suspension_metadata_changed?(latest_suspension)
latest_suspension.values_at('category', 'reason') != suspension_details.values_at(:category, :reason)
end
def suspension_details
@suspension_details ||= {
category: params.dig(:account, :suspension_category).to_s,
reason: params.dig(:account, :suspension_reason).to_s.strip
}
end
def target_status
params.dig(:account, :status).to_s
end
end end
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')

View File

@@ -33,7 +33,8 @@ class AccountDashboard < Administrate::BaseDashboard
users: CountField, users: CountField,
conversations: CountField, conversations: CountField,
locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }), locale: Field::Select.with_options(collection: LANGUAGES_CONFIG.map { |_x, y| y[:iso_639_1_code] }),
status: Field::Select.with_options(collection: [%w[Active active], %w[Suspended suspended]]), status: AccountStatusField.with_options(collection: [%w[Active active], %w[Suspended suspended]]),
suspension_history: SuspensionHistoryField,
account_users: Field::HasMany, account_users: Field::HasMany,
custom_attributes: Field::String custom_attributes: Field::String
}.merge(enterprise_attribute_types).freeze }.merge(enterprise_attribute_types).freeze
@@ -70,6 +71,7 @@ class AccountDashboard < Administrate::BaseDashboard
updated_at updated_at
locale locale
status status
suspension_history
conversations conversations
account_users account_users
] + enterprise_show_page_attributes).freeze ] + enterprise_show_page_attributes).freeze
@@ -121,6 +123,7 @@ class AccountDashboard < Administrate::BaseDashboard
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
def permitted_attributes(action) def permitted_attributes(action)
attrs = super + [limits: {}, captain_models: {}] attrs = super + [limits: {}, captain_models: {}]
attrs += %i[suspension_category suspension_reason] if action == 'update'
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud # Add manually_managed_features to permitted attributes only for Chatwoot Cloud
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?

View File

@@ -0,0 +1,23 @@
require 'administrate/field/select'
class AccountStatusField < Administrate::Field::Select
def to_partial_path
return '/fields/account_status_field/form' if page == :form
"/fields/select/#{page}"
end
def suspension_category_options
Account::SUSPENSION_CATEGORIES.map do |category|
[I18n.t("super_admin.account_suspension.categories.#{category}"), category]
end
end
def latest_suspension
resource.suspension_history.last || {}
end
def original_status
resource.status_in_database || resource.status
end
end

View File

@@ -0,0 +1,15 @@
require 'administrate/field/base'
class SuspensionHistoryField < Administrate::Field::Base
def events
data.reverse
end
def category_label(event)
I18n.t("super_admin.account_suspension.categories.#{event.fetch('category')}")
end
def suspended_at(event)
I18n.l(Time.zone.parse(event.fetch('suspended_at')), format: :long)
end
end

View File

@@ -1 +1,38 @@
import '../dashboard/assets/scss/super_admin/index.scss'; import '../dashboard/assets/scss/super_admin/index.scss';
const initializeAccountSuspensionForm = () => {
const form = document.querySelector('[data-account-suspension-form]');
if (!form) return;
const status = form.querySelector('[data-account-status-select]');
const fields = form.querySelector('[data-account-suspension-fields]');
if (!status || !fields) return;
const category = fields.querySelector('[data-suspension-category]');
const reason = fields.querySelector('[data-suspension-reason]');
const controls = [category, reason];
const originalStatus = form.dataset.originalStatus;
const hasHistory = form.dataset.hasSuspensionHistory === 'true';
const updateFields = () => {
const isSuspended = status.value === 'suspended';
const hasEnteredDetails = controls.some(
control => control.value.trim().length > 0
);
const detailsRequired =
isSuspended &&
(originalStatus === 'active' || hasHistory || hasEnteredDetails);
fields.classList.toggle('hidden', !isSuspended);
controls.forEach(control => {
control.disabled = !isSuspended;
control.required = detailsRequired;
});
};
status.addEventListener('change', updateFields);
controls.forEach(control => control.addEventListener('input', updateFields));
updateFields();
};
document.addEventListener('DOMContentLoaded', initializeAccountSuspensionForm);

View File

@@ -37,6 +37,9 @@ class Account < ApplicationRecord
flag_query_mode: :bit_operator, flag_query_mode: :bit_operator,
check_for_column: false check_for_column: false
}.freeze }.freeze
SUSPENSION_CATEGORIES = %w[spam non_payment other].freeze
attr_accessor :suspension_category, :suspension_reason
validates :name, presence: true validates :name, presence: true
# `domain` is the inbound email domain used to construct reply addresses # `domain` is the inbound email domain used to construct reply addresses
@@ -138,6 +141,10 @@ class Account < ApplicationRecord
} }
end end
def suspension_history
internal_attributes['suspensions'] || []
end
def inbound_email_domain def inbound_email_domain
domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN',
false) false)

View File

@@ -0,0 +1,65 @@
<div class="field-unit__label">
<%= f.label field.attribute %>
</div>
<div class="field-unit__field" data-account-suspension-form data-original-status="<%= field.original_status %>" data-has-suspension-history="<%= field.resource.suspension_history.present? %>">
<%= f.select(
field.attribute,
options_for_select(field.selectable_options, field.data),
{ include_blank: field.include_blank_option },
{
class: 'rounded border border-n-weak bg-white p-2',
data: { account_status_select: true }
}
) %>
<% if f.object.persisted? %>
<%
submitted_account = params[:account]
latest_suspension = field.resource.suspended? ? field.latest_suspension : {}
category = if submitted_account&.key?(:suspension_category)
submitted_account[:suspension_category]
else
latest_suspension['category']
end
reason = if submitted_account&.key?(:suspension_reason)
submitted_account[:suspension_reason]
else
latest_suspension['reason']
end
suspension_selected = field.data == 'suspended'
%>
<div class="mt-6 space-y-4 <%= 'hidden' unless suspension_selected %>" data-account-suspension-fields>
<div>
<%= label_tag :account_suspension_category, t('super_admin.account_suspension.form.category'), class: 'font-normal' %>
<%= select_tag(
'account[suspension_category]',
options_for_select(
[[t('super_admin.account_suspension.form.category_prompt'), '']] + field.suspension_category_options,
category
),
id: :account_suspension_category,
disabled: !suspension_selected,
class: 'mt-1 rounded border border-n-weak bg-white p-2',
data: { suspension_category: true }
) %>
</div>
<div>
<%= label_tag :account_suspension_reason, t('super_admin.account_suspension.form.reason'), class: 'font-normal' %>
<%= text_area_tag(
'account[suspension_reason]',
reason,
id: :account_suspension_reason,
rows: 4,
maxlength: 256,
disabled: !suspension_selected,
class: 'mt-1 rounded border border-n-weak bg-white p-2',
data: { suspension_reason: true }
) %>
<p class="mt-1 text-xs text-n-slate-11"><%= t('super_admin.account_suspension.form.reason_hint') %></p>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,24 @@
<% if field.events.present? %>
<div class="w-full overflow-x-auto">
<table class="min-w-full divide-y divide-n-weak text-left text-sm">
<thead>
<tr class="text-n-slate-11">
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.suspended_at') %></th>
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.category') %></th>
<th class="px-3 py-2 font-medium"><%= t('super_admin.account_suspension.history.reason') %></th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<% field.events.each do |event| %>
<tr class="align-top text-n-slate-12">
<td class="whitespace-nowrap px-3 py-3"><%= field.suspended_at(event) %></td>
<td class="whitespace-nowrap px-3 py-3"><%= field.category_label(event) %></td>
<td class="whitespace-pre-wrap break-words px-3 py-3"><%= event.fetch('reason') %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="text-sm text-n-slate-11"><%= t('super_admin.account_suspension.history.empty') %></p>
<% end %>

View File

@@ -0,0 +1,19 @@
<% content_for(:title) { t("administrate.actions.edit_resource", name: page.page_title) } %>
<header class="main-content__header">
<h1 class="main-content__page-title">
<%= content_for(:title) %>
</h1>
<div>
<%= link_to(
t("administrate.actions.show_resource", name: page.page_title),
[namespace, page.resource],
class: "button"
) if accessible_action?(page.resource, :show) %>
</div>
</header>
<section class="main-content__body [&_.field-unit]:border-b [&_.field-unit]:border-n-weak [&_.field-unit]:pb-6">
<%= render "form", page: page %>
</section>

View File

@@ -609,6 +609,21 @@ en:
ssl_status: ssl_status:
custom_domain_not_configured: 'Custom domain is not configured' custom_domain_not_configured: 'Custom domain is not configured'
super_admin: super_admin:
account_suspension:
categories:
spam: 'Spam'
non_payment: 'Non-payment'
other: 'Other'
form:
category: 'Suspension category'
category_prompt: 'Select a category'
reason: 'Suspension reason'
reason_hint: 'Enter a reason of up to 256 characters.'
history:
suspended_at: 'Suspended at'
category: 'Category'
reason: 'Reason'
empty: 'No suspension history recorded.'
captain_model_overrides: captain_model_overrides:
form: form:
helper_text: 'Leave a model blank to use the YAML default for that AI feature.' helper_text: 'Leave a model blank to use the YAML default for that AI feature.'

View File

@@ -2,15 +2,15 @@
<%= f.label field.attribute %> <%= f.label field.attribute %>
</div> </div>
<div class="field-unit__field"> <div class="field-unit__field max-w-5xl">
<p class="text-gray-400 text-xs italic mb-4"><%= t('super_admin.captain_model_overrides.form.helper_text') %></p> <p class="mb-3 text-xs italic text-n-slate-10"><%= t('super_admin.captain_model_overrides.form.helper_text') %></p>
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<% field.feature_rows.each do |feature| %> <% field.feature_rows.each do |feature| %>
<div class="p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container"> <div class="rounded-md border border-n-weak bg-white p-2.5">
<div class="mb-2"> <div class="mb-1.5">
<div class="text-sm font-medium text-slate-700"><%= feature[:name] %></div> <div class="text-xs font-medium text-n-slate-12"><%= feature[:name] %></div>
<div class="text-xs text-slate-500"><%= feature[:key] %></div> <div class="text-[11px] text-n-slate-10"><%= feature[:key] %></div>
</div> </div>
<%= select_tag( <%= select_tag(
@@ -19,7 +19,7 @@
[[t('super_admin.captain_model_overrides.form.use_default', model: feature[:default_model], model_id: feature[:default_model_id]), '']] + feature[:options], [[t('super_admin.captain_model_overrides.form.use_default', model: feature[:default_model], model_id: feature[:default_model_id]), '']] + feature[:options],
feature[:selected_override] feature[:selected_override]
), ),
class: 'block w-full rounded-md border-slate-300 text-sm' class: 'block w-full rounded-md border-n-strong py-1.5 pl-2 pr-8 text-xs'
) %> ) %>
</div> </div>
<% end %> <% end %>

View File

@@ -7,29 +7,29 @@
</summary> </summary>
<div class="mt-3 w-full"> <div class="mt-3 w-full">
<div class="grid grid-cols-1 gap-3"> <div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
<% field.feature_rows.each do |feature| %> <% field.feature_rows.each do |feature| %>
<div class="p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm"> <div class="rounded-md border border-n-weak bg-white p-2.5">
<div class="flex items-center justify-between gap-4"> <div class="flex items-start justify-between gap-2">
<div> <div>
<div class="text-sm font-medium text-n-slate-12"><%= feature[:name] %></div> <div class="text-xs font-medium text-n-slate-12"><%= feature[:name] %></div>
<div class="text-xs text-n-slate-11"><%= feature[:key] %></div> <div class="text-[11px] text-n-slate-10"><%= feature[:key] %></div>
</div> </div>
<span class="<%= feature[:source] == :account_override ? 'bg-green-400 text-white' : 'bg-slate-50 text-slate-800' %> rounded-full px-2 py-1 text-xs"> <span class="<%= feature[:source] == :account_override ? 'bg-green-400 text-white' : 'bg-slate-50 text-slate-800' %> shrink-0 rounded-full px-2 py-1 text-[11px]">
<%= feature[:source_label] %> <%= feature[:source_label] %>
</span> </span>
</div> </div>
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"> <div class="mt-2 grid grid-cols-1 gap-2 text-xs">
<div> <div>
<div class="text-xs uppercase text-n-slate-10"><%= t('super_admin.captain_model_overrides.show.provider') %></div> <div class="text-[11px] uppercase text-n-slate-10"><%= t('super_admin.captain_model_overrides.show.provider') %></div>
<div class="text-n-slate-12"> <div class="text-n-slate-12">
<%= feature[:provider] %> <%= feature[:provider] %>
<span class="text-n-slate-10">(<%= feature[:provider_id] %>)</span> <span class="text-n-slate-10">(<%= feature[:provider_id] %>)</span>
</div> </div>
</div> </div>
<div> <div>
<div class="text-xs uppercase text-n-slate-10"><%= t('super_admin.captain_model_overrides.show.model') %></div> <div class="text-[11px] uppercase text-n-slate-10"><%= t('super_admin.captain_model_overrides.show.model') %></div>
<div class="text-n-slate-12"> <div class="text-n-slate-12">
<%= feature[:model] %> <%= feature[:model] %>
<span class="text-n-slate-10">(<%= feature[:model_id] %>)</span> <span class="text-n-slate-10">(<%= feature[:model_id] %>)</span>