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] %>)