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 @@ +
<%= t('super_admin.account_suspension.form.reason_hint') %>
+| <%= 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') %> | +
<%= 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) } %> + +<%= t('super_admin.captain_model_overrides.form.helper_text') %>
+<%= t('super_admin.captain_model_overrides.form.helper_text') %>
-