From 7c1711170bd46d0be97ff1e40a0b1d347d7d3d14 Mon Sep 17 00:00:00 2001 From: Sony Mathew Date: Fri, 24 Jul 2026 14:48:14 +0530 Subject: [PATCH] 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 --- .../super_admin/accounts_controller.rb | 90 +++++++++++++++++++ app/dashboards/account_dashboard.rb | 5 +- app/fields/account_status_field.rb | 23 +++++ app/fields/suspension_history_field.rb | 15 ++++ app/javascript/entrypoints/superadmin.js | 37 ++++++++ app/models/account.rb | 7 ++ .../account_status_field/_form.html.erb | 65 ++++++++++++++ .../suspension_history_field/_show.html.erb | 24 +++++ app/views/super_admin/accounts/edit.html.erb | 19 ++++ config/locales/en.yml | 15 ++++ .../_form.html.erb | 16 ++-- .../_show.html.erb | 18 ++-- 12 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 app/fields/account_status_field.rb create mode 100644 app/fields/suspension_history_field.rb create mode 100644 app/views/fields/account_status_field/_form.html.erb create mode 100644 app/views/fields/suspension_history_field/_show.html.erb create mode 100644 app/views/super_admin/accounts/edit.html.erb diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 59b99c37e..584e87a73 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -1,4 +1,6 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController + before_action :validate_suspension_metadata, only: :update + # 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. # @@ -35,12 +37,18 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # def resource_params 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[: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 end + def update + apply_suspension_metadata + super + end + # See https://administrate-prototype.herokuapp.com/customizing_controller_actions # 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.') # rubocop:enable Rails/I18nLocaleTexts 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 SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index b2683f2e0..84246fff8 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -33,7 +33,8 @@ class AccountDashboard < Administrate::BaseDashboard users: CountField, conversations: CountField, 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, custom_attributes: Field::String }.merge(enterprise_attribute_types).freeze @@ -70,6 +71,7 @@ class AccountDashboard < Administrate::BaseDashboard updated_at locale status + suspension_history conversations account_users ] + enterprise_show_page_attributes).freeze @@ -121,6 +123,7 @@ class AccountDashboard < Administrate::BaseDashboard # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 def permitted_attributes(action) 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 attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud? diff --git a/app/fields/account_status_field.rb b/app/fields/account_status_field.rb new file mode 100644 index 000000000..52c56123d --- /dev/null +++ b/app/fields/account_status_field.rb @@ -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 diff --git a/app/fields/suspension_history_field.rb b/app/fields/suspension_history_field.rb new file mode 100644 index 000000000..e70ab86de --- /dev/null +++ b/app/fields/suspension_history_field.rb @@ -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 diff --git a/app/javascript/entrypoints/superadmin.js b/app/javascript/entrypoints/superadmin.js index bfad82f93..b63b84a48 100644 --- a/app/javascript/entrypoints/superadmin.js +++ b/app/javascript/entrypoints/superadmin.js @@ -1 +1,38 @@ 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); diff --git a/app/models/account.rb b/app/models/account.rb index 00201739a..8d5d61717 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -37,6 +37,9 @@ class Account < ApplicationRecord flag_query_mode: :bit_operator, check_for_column: false }.freeze + SUSPENSION_CATEGORIES = %w[spam non_payment other].freeze + + attr_accessor :suspension_category, :suspension_reason validates :name, presence: true # `domain` is the inbound email domain used to construct reply addresses @@ -138,6 +141,10 @@ class Account < ApplicationRecord } end + def suspension_history + internal_attributes['suspensions'] || [] + end + def inbound_email_domain domain.presence || GlobalConfig.get('MAILER_INBOUND_EMAIL_DOMAIN')['MAILER_INBOUND_EMAIL_DOMAIN'] || ENV.fetch('MAILER_INBOUND_EMAIL_DOMAIN', false) diff --git a/app/views/fields/account_status_field/_form.html.erb b/app/views/fields/account_status_field/_form.html.erb new file mode 100644 index 000000000..655c1467c --- /dev/null +++ b/app/views/fields/account_status_field/_form.html.erb @@ -0,0 +1,65 @@ +
+ <%= f.label field.attribute %> +
+ +
+ <%= 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' + %> + +
+
+ <%= 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 } + ) %> +
+ +
+ <%= 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 } + ) %> +

<%= t('super_admin.account_suspension.form.reason_hint') %>

+
+
+ <% end %> +
diff --git a/app/views/fields/suspension_history_field/_show.html.erb b/app/views/fields/suspension_history_field/_show.html.erb new file mode 100644 index 000000000..7df7a3593 --- /dev/null +++ b/app/views/fields/suspension_history_field/_show.html.erb @@ -0,0 +1,24 @@ +<% if field.events.present? %> +
+ + + + + + + + + + <% field.events.each do |event| %> + + + + + + <% end %> + +
<%= t('super_admin.account_suspension.history.suspended_at') %><%= t('super_admin.account_suspension.history.category') %><%= t('super_admin.account_suspension.history.reason') %>
<%= field.suspended_at(event) %><%= field.category_label(event) %><%= event.fetch('reason') %>
+
+<% else %> +

<%= t('super_admin.account_suspension.history.empty') %>

+<% end %> diff --git a/app/views/super_admin/accounts/edit.html.erb b/app/views/super_admin/accounts/edit.html.erb new file mode 100644 index 000000000..59b6d8d81 --- /dev/null +++ b/app/views/super_admin/accounts/edit.html.erb @@ -0,0 +1,19 @@ +<% content_for(:title) { t("administrate.actions.edit_resource", name: page.page_title) } %> + +
+

+ <%= content_for(:title) %> +

+ +
+ <%= link_to( + t("administrate.actions.show_resource", name: page.page_title), + [namespace, page.resource], + class: "button" + ) if accessible_action?(page.resource, :show) %> +
+
+ +
+ <%= render "form", page: page %> +
diff --git a/config/locales/en.yml b/config/locales/en.yml index 96c823f6a..2b2cb458c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -609,6 +609,21 @@ en: ssl_status: custom_domain_not_configured: 'Custom domain is not configured' 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: form: helper_text: 'Leave a model blank to use the YAML default for that AI feature.' diff --git a/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb b/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb index 0420ab09a..ff1842e96 100644 --- a/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb +++ b/enterprise/app/views/fields/captain_model_overrides_field/_form.html.erb @@ -2,15 +2,15 @@ <%= f.label field.attribute %> -
-

<%= t('super_admin.captain_model_overrides.form.helper_text') %>

+
+

<%= t('super_admin.captain_model_overrides.form.helper_text') %>

-
+
<% field.feature_rows.each do |feature| %> -
-
-
<%= feature[:name] %>
-
<%= feature[:key] %>
+
+
+
<%= feature[:name] %>
+
<%= feature[:key] %>
<%= 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], 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' ) %>
<% end %> diff --git a/enterprise/app/views/fields/captain_model_overrides_field/_show.html.erb b/enterprise/app/views/fields/captain_model_overrides_field/_show.html.erb index 4215e93aa..43c967e61 100644 --- a/enterprise/app/views/fields/captain_model_overrides_field/_show.html.erb +++ b/enterprise/app/views/fields/captain_model_overrides_field/_show.html.erb @@ -7,29 +7,29 @@
-
+
<% field.feature_rows.each do |feature| %> -
-
+
+
-
<%= feature[:name] %>
-
<%= feature[:key] %>
+
<%= feature[:name] %>
+
<%= feature[:key] %>
- + <%= feature[:source_label] %>
-
+
-
<%= t('super_admin.captain_model_overrides.show.provider') %>
+
<%= t('super_admin.captain_model_overrides.show.provider') %>
<%= feature[:provider] %> (<%= feature[:provider_id] %>)
-
<%= t('super_admin.captain_model_overrides.show.model') %>
+
<%= t('super_admin.captain_model_overrides.show.model') %>
<%= feature[:model] %> (<%= feature[:model_id] %>)