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:
@@ -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')
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
23
app/fields/account_status_field.rb
Normal file
23
app/fields/account_status_field.rb
Normal 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
|
||||||
15
app/fields/suspension_history_field.rb
Normal file
15
app/fields/suspension_history_field.rb
Normal 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
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
65
app/views/fields/account_status_field/_form.html.erb
Normal file
65
app/views/fields/account_status_field/_form.html.erb
Normal 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>
|
||||||
24
app/views/fields/suspension_history_field/_show.html.erb
Normal file
24
app/views/fields/suspension_history_field/_show.html.erb
Normal 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 %>
|
||||||
19
app/views/super_admin/accounts/edit.html.erb
Normal file
19
app/views/super_admin/accounts/edit.html.erb
Normal 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>
|
||||||
@@ -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.'
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user