rsb-admin 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +83 -0
  4. data/Rakefile +25 -0
  5. data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
  6. data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
  7. data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
  8. data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
  9. data/app/controllers/rsb/admin/admin_controller.rb +138 -0
  10. data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
  11. data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
  12. data/app/controllers/rsb/admin/profile_controller.rb +146 -0
  13. data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
  14. data/app/controllers/rsb/admin/resources_controller.rb +386 -0
  15. data/app/controllers/rsb/admin/roles_controller.rb +99 -0
  16. data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
  17. data/app/controllers/rsb/admin/settings_controller.rb +203 -0
  18. data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
  19. data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
  20. data/app/helpers/rsb/admin/branding_helper.rb +38 -0
  21. data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
  22. data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
  23. data/app/helpers/rsb/admin/icons_helper.rb +55 -0
  24. data/app/helpers/rsb/admin/table_helper.rb +132 -0
  25. data/app/helpers/rsb/admin/theme_helper.rb +84 -0
  26. data/app/helpers/rsb/admin/url_helper.rb +109 -0
  27. data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
  28. data/app/models/rsb/admin/admin_session.rb +109 -0
  29. data/app/models/rsb/admin/admin_user.rb +153 -0
  30. data/app/models/rsb/admin/application_record.rb +10 -0
  31. data/app/models/rsb/admin/role.rb +63 -0
  32. data/app/views/layouts/rsb/admin/application.html.erb +45 -0
  33. data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
  34. data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
  35. data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
  36. data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
  37. data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
  38. data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
  39. data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
  40. data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
  41. data/app/views/rsb/admin/profile/edit.html.erb +67 -0
  42. data/app/views/rsb/admin/profile/show.html.erb +155 -0
  43. data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
  44. data/app/views/rsb/admin/resources/_form.html.erb +20 -0
  45. data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
  46. data/app/views/rsb/admin/resources/_table.html.erb +70 -0
  47. data/app/views/rsb/admin/resources/edit.html.erb +7 -0
  48. data/app/views/rsb/admin/resources/index.html.erb +49 -0
  49. data/app/views/rsb/admin/resources/new.html.erb +7 -0
  50. data/app/views/rsb/admin/resources/page.html.erb +9 -0
  51. data/app/views/rsb/admin/resources/show.html.erb +55 -0
  52. data/app/views/rsb/admin/roles/_form.html.erb +197 -0
  53. data/app/views/rsb/admin/roles/edit.html.erb +7 -0
  54. data/app/views/rsb/admin/roles/index.html.erb +71 -0
  55. data/app/views/rsb/admin/roles/new.html.erb +7 -0
  56. data/app/views/rsb/admin/roles/show.html.erb +99 -0
  57. data/app/views/rsb/admin/sessions/new.html.erb +31 -0
  58. data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
  59. data/app/views/rsb/admin/settings/_field.html.erb +115 -0
  60. data/app/views/rsb/admin/settings/index.html.erb +61 -0
  61. data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
  62. data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
  63. data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
  64. data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
  65. data/app/views/rsb/admin/shared/_header.html.erb +50 -0
  66. data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
  67. data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
  68. data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
  69. data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
  70. data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
  71. data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
  72. data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
  73. data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
  74. data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
  75. data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
  76. data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
  77. data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
  78. data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
  79. data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
  80. data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
  81. data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
  82. data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
  83. data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
  84. data/config/locales/en.yml +140 -0
  85. data/config/locales/seo.en.yml +21 -0
  86. data/config/routes.rb +59 -0
  87. data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
  88. data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
  89. data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
  90. data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
  91. data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
  92. data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
  93. data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
  94. data/lib/rsb/admin/breadcrumb_item.rb +26 -0
  95. data/lib/rsb/admin/category_registration.rb +177 -0
  96. data/lib/rsb/admin/column_definition.rb +89 -0
  97. data/lib/rsb/admin/configuration.rb +69 -0
  98. data/lib/rsb/admin/engine.rb +34 -0
  99. data/lib/rsb/admin/filter_definition.rb +129 -0
  100. data/lib/rsb/admin/form_field_definition.rb +96 -0
  101. data/lib/rsb/admin/icons.rb +95 -0
  102. data/lib/rsb/admin/page_registration.rb +140 -0
  103. data/lib/rsb/admin/registry.rb +109 -0
  104. data/lib/rsb/admin/resource_dsl_context.rb +139 -0
  105. data/lib/rsb/admin/resource_registration.rb +287 -0
  106. data/lib/rsb/admin/settings_schema.rb +60 -0
  107. data/lib/rsb/admin/test_kit/helpers.rb +316 -0
  108. data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
  109. data/lib/rsb/admin/test_kit.rb +11 -0
  110. data/lib/rsb/admin/theme_definition.rb +46 -0
  111. data/lib/rsb/admin/themes/modern.rb +44 -0
  112. data/lib/rsb/admin/version.rb +9 -0
  113. data/lib/rsb/admin.rb +177 -0
  114. data/lib/tasks/rsb/admin_tasks.rake +23 -0
  115. metadata +227 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class SettingsController < AdminController
6
+ before_action :authorize_settings
7
+
8
+ # GET /admin/settings?tab=auth
9
+ #
10
+ # Renders the settings page with tabbed navigation.
11
+ # Loads settings for the active tab category only.
12
+ # Resolves current values, locked status, and depends_on states
13
+ # for each setting in the active category.
14
+ #
15
+ # @return [void]
16
+ def index
17
+ @rsb_page_title = I18n.t('rsb.admin.settings.page_title', default: 'Settings')
18
+ @categories = RSB::Settings.registry.categories
19
+ @active_tab = resolve_active_tab
20
+
21
+ if @active_tab
22
+ @groups = RSB::Settings.registry.grouped_definitions(@active_tab)
23
+ @field_states = build_field_states(@active_tab, @groups)
24
+ @current_values = build_current_values(@active_tab, @groups)
25
+ else
26
+ @groups = {}
27
+ @field_states = {}
28
+ @current_values = {}
29
+ end
30
+ end
31
+
32
+ # PATCH /admin/settings
33
+ #
34
+ # Batch update settings for a single category.
35
+ # Receives all field values for the category, diffs against current values,
36
+ # and only persists changed values. Skips locked settings and settings
37
+ # disabled by depends_on.
38
+ #
39
+ # @return [void] redirects to settings page with tab preserved
40
+ def batch_update
41
+ category = params.dig(:settings, :category)
42
+ tab = params.dig(:settings, :tab) || category
43
+
44
+ schema = RSB::Settings.registry.for(category)
45
+ unless schema
46
+ redirect_to rsb_admin.settings_path, alert: 'Unknown settings category.'
47
+ return
48
+ end
49
+
50
+ submitted = params.dig(:settings, :values)&.to_unsafe_h || {}
51
+
52
+ begin
53
+ ActiveRecord::Base.transaction do
54
+ schema.definitions.each do |defn|
55
+ key_str = defn.key.to_s
56
+ next unless submitted.key?(key_str)
57
+
58
+ full_key = "#{category}.#{key_str}"
59
+
60
+ # Skip locked settings (defense-in-depth)
61
+ next if RSB::Settings.configuration.locked?(full_key)
62
+
63
+ # Skip depends_on disabled settings (defense-in-depth)
64
+ if defn.depends_on.present?
65
+ parent_value = RSB::Settings.get(defn.depends_on)
66
+ next unless parent_truthy?(parent_value)
67
+ end
68
+
69
+ # Compare with current value (cast submitted string for fair comparison)
70
+ current = RSB::Settings.get(full_key)
71
+ submitted_val = submitted[key_str]
72
+
73
+ RSB::Settings.set(full_key, submitted_val) unless values_equal?(current, submitted_val, defn.type)
74
+ end
75
+ end
76
+ rescue RSB::Settings::ValidationError => e
77
+ RSB::Settings.invalidate_cache!
78
+ redirect_to rsb_admin.settings_path(tab: tab), alert: e.message
79
+ return
80
+ end
81
+
82
+ redirect_to rsb_admin.settings_path(tab: tab), notice: 'Settings updated successfully.'
83
+ end
84
+
85
+ # Existing single-setting update (kept for backward compatibility)
86
+ def update
87
+ key = "#{params[:category]}.#{params[:key]}"
88
+
89
+ if RSB::Settings.configuration.locked?(key)
90
+ redirect_to rsb_admin.settings_path, alert: 'Setting is locked.'
91
+ return
92
+ end
93
+
94
+ RSB::Settings.set(key, params[:value])
95
+ redirect_to rsb_admin.settings_path, notice: 'Setting updated.'
96
+ end
97
+
98
+ private
99
+
100
+ # Resolve the active tab from params, falling back to the first category.
101
+ #
102
+ # @return [String, nil] the active category name, or nil if no categories exist
103
+ def resolve_active_tab
104
+ categories = RSB::Settings.registry.categories
105
+ return nil if categories.empty?
106
+
107
+ tab = params[:tab]
108
+ categories.include?(tab) ? tab : categories.first
109
+ end
110
+
111
+ # Build a hash of setting key -> field state for the active category.
112
+ # States: :editable, :locked, :disabled_by_dependency
113
+ #
114
+ # @param category [String]
115
+ # @param groups [Hash<String, Array<SettingDefinition>>]
116
+ # @return [Hash<Symbol, Symbol>] setting key -> state
117
+ def build_field_states(category, groups)
118
+ states = {}
119
+ locked_keys = RSB::Settings.configuration.locked_keys
120
+
121
+ groups.each_value do |definitions|
122
+ definitions.each do |defn|
123
+ full_key = "#{category}.#{defn.key}"
124
+
125
+ if locked_keys.include?(full_key)
126
+ states[defn.key] = :locked
127
+ elsif defn.depends_on.present?
128
+ parent_value = RSB::Settings.get(defn.depends_on)
129
+ states[defn.key] = parent_truthy?(parent_value) ? :editable : :disabled_by_dependency
130
+ else
131
+ states[defn.key] = :editable
132
+ end
133
+ end
134
+ end
135
+
136
+ states
137
+ end
138
+
139
+ # Build a hash of setting key -> current resolved value for the active category.
140
+ #
141
+ # @param category [String]
142
+ # @param groups [Hash<String, Array<SettingDefinition>>]
143
+ # @return [Hash<Symbol, Object>] setting key -> current value
144
+ def build_current_values(category, groups)
145
+ values = {}
146
+ groups.each_value do |definitions|
147
+ definitions.each do |defn|
148
+ values[defn.key] = RSB::Settings.get("#{category}.#{defn.key}")
149
+ end
150
+ end
151
+ values
152
+ end
153
+
154
+ # Check if a parent setting value is truthy for depends_on resolution.
155
+ # Falsy: false, nil, 0, "", "false", "0"
156
+ #
157
+ # @param value [Object]
158
+ # @return [Boolean]
159
+ def parent_truthy?(value)
160
+ return false if value.nil?
161
+ return false if value == false
162
+ return false if value == 0
163
+ return false if value.is_a?(String) && value.blank?
164
+ return false if value.to_s.downcase == 'false'
165
+ return false if value.to_s == '0'
166
+
167
+ true
168
+ end
169
+
170
+ # Compare current value to submitted value with type-appropriate casting.
171
+ # Submitted values come as strings from HTML forms.
172
+ #
173
+ # @param current [Object] the current resolved value
174
+ # @param submitted [String] the submitted form value
175
+ # @param type [Symbol] the setting type
176
+ # @return [Boolean] true if values are equal
177
+ def values_equal?(current, submitted, type)
178
+ case type
179
+ when :boolean
180
+ current_bool = ActiveModel::Type::Boolean.new.cast(current)
181
+ submitted_bool = ActiveModel::Type::Boolean.new.cast(submitted)
182
+ current_bool == submitted_bool
183
+ when :integer
184
+ current.to_i == submitted.to_i
185
+ when :float
186
+ current.to_f == submitted.to_f
187
+ else
188
+ current.to_s == submitted.to_s
189
+ end
190
+ end
191
+
192
+ def build_breadcrumbs
193
+ super
194
+ add_breadcrumb(I18n.t('rsb.admin.shared.system'))
195
+ add_breadcrumb(I18n.t('rsb.admin.settings.title'))
196
+ end
197
+
198
+ def authorize_settings
199
+ authorize_admin_action!(resource: 'settings', action: action_name)
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Handles TOTP 2FA enrollment, backup codes display, and disable.
6
+ # All actions require admin authentication (inherited from AdminController).
7
+ class TwoFactorController < AdminController
8
+ skip_before_action :enforce_two_factor_enrollment
9
+
10
+ # GET /admin/profile/two_factor/new
11
+ # Renders the enrollment page with QR code and manual entry key.
12
+ def new
13
+ @admin_user = current_admin_user
14
+ @otp_secret = @admin_user.generate_otp_secret!
15
+ session[:rsb_admin_otp_provisional_secret] = @otp_secret
16
+
17
+ issuer = begin
18
+ RSB::Settings.get('admin.app_name')
19
+ rescue StandardError
20
+ 'RSB Admin'
21
+ end
22
+ @otp_uri = @admin_user.otp_provisioning_uri(@otp_secret, issuer: issuer)
23
+ @qr_svg = RQRCode::QRCode.new(@otp_uri).as_svg(
24
+ module_size: 4,
25
+ standalone: true,
26
+ use_path: true
27
+ )
28
+ end
29
+
30
+ # POST /admin/profile/two_factor
31
+ # Confirms enrollment by verifying the TOTP code against the provisional secret.
32
+ def create
33
+ @admin_user = current_admin_user
34
+ @otp_secret = session[:rsb_admin_otp_provisional_secret]
35
+
36
+ unless @otp_secret
37
+ redirect_to rsb_admin.new_profile_two_factor_path, alert: 'Enrollment session expired. Please try again.'
38
+ return
39
+ end
40
+
41
+ totp = ROTP::TOTP.new(@otp_secret)
42
+ if totp.verify(params[:otp_code].to_s, drift_behind: 30, drift_ahead: 30)
43
+ # Persist OTP secret and enable 2FA
44
+ @admin_user.update!(otp_secret: @otp_secret, otp_required: true)
45
+
46
+ # Generate backup codes
47
+ codes = @admin_user.generate_backup_codes!
48
+ session[:rsb_admin_backup_codes] = codes
49
+ session.delete(:rsb_admin_otp_provisional_secret)
50
+ session.delete(:rsb_admin_force_2fa_enrollment)
51
+
52
+ redirect_to rsb_admin.profile_two_factor_backup_codes_path
53
+ else
54
+ # Re-render enrollment page with error
55
+ issuer = begin
56
+ RSB::Settings.get('admin.app_name')
57
+ rescue StandardError
58
+ 'RSB Admin'
59
+ end
60
+ @otp_uri = @admin_user.otp_provisioning_uri(@otp_secret, issuer: issuer)
61
+ @qr_svg = RQRCode::QRCode.new(@otp_uri).as_svg(
62
+ module_size: 4,
63
+ standalone: true,
64
+ use_path: true
65
+ )
66
+ flash.now[:alert] = 'Invalid verification code. Please try again.'
67
+ render :new, status: :unprocessable_entity
68
+ end
69
+ end
70
+
71
+ # GET /admin/profile/two_factor/backup_codes
72
+ # Displays backup codes one time after enrollment.
73
+ def backup_codes
74
+ @backup_codes = session.delete(:rsb_admin_backup_codes)
75
+ return if @backup_codes
76
+
77
+ redirect_to rsb_admin.profile_path, alert: 'Backup codes are only shown once after enrollment.'
78
+ nil
79
+ end
80
+
81
+ # DELETE /admin/profile/two_factor
82
+ # Disables 2FA with current password confirmation.
83
+ def destroy
84
+ admin = current_admin_user
85
+
86
+ unless admin.authenticate(params[:current_password].to_s)
87
+ redirect_to rsb_admin.profile_path, alert: 'Incorrect password.'
88
+ return
89
+ end
90
+
91
+ admin.disable_otp!
92
+ redirect_to rsb_admin.profile_path, notice: 'Two-factor authentication disabled.'
93
+ end
94
+
95
+ private
96
+
97
+ # Override breadcrumbs for 2FA pages
98
+ def build_breadcrumbs
99
+ super
100
+ add_breadcrumb(I18n.t('rsb.admin.profile.title', default: 'Profile'), rsb_admin.profile_path)
101
+ add_breadcrumb('Two-Factor Authentication')
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper for permission-aware rendering in admin views.
6
+ #
7
+ # Provides a convenience method to check if the current admin user
8
+ # has permission to access a given resource and action. Used in views
9
+ # to conditionally render or disable UI elements based on RBAC permissions.
10
+ # This helper is automatically included in all RSB::Admin controllers
11
+ # via the AdminController base class.
12
+ #
13
+ # @example Check permission in a view
14
+ # <% if rsb_admin_can?("identities", "index") %>
15
+ # <a href="/admin/identities">Identities</a>
16
+ # <% end %>
17
+ #
18
+ # @example Conditionally render action button
19
+ # <% if rsb_admin_can?("articles", "edit") %>
20
+ # <%= link_to "Edit", edit_admin_article_path(@article) %>
21
+ # <% end %>
22
+ module AuthorizationHelper
23
+ # Check if the current admin user has permission for a resource action.
24
+ #
25
+ # Delegates to `current_admin_user.can?` for the actual permission check.
26
+ # Returns false if there is no current admin user (safety fallback).
27
+ #
28
+ # @param resource [String] the resource key (e.g., "identities", "dashboard")
29
+ # @param action [String] the action name (e.g., "index", "show", "edit")
30
+ #
31
+ # @return [Boolean] true if the user has permission, false otherwise
32
+ #
33
+ # @example Check dashboard access
34
+ # rsb_admin_can?("dashboard", "index") #=> true/false
35
+ #
36
+ # @example Check resource edit permission
37
+ # rsb_admin_can?("identities", "edit") #=> true/false
38
+ #
39
+ # @example No current user returns false
40
+ # # When current_admin_user is nil
41
+ # rsb_admin_can?("roles", "index") #=> false
42
+ def rsb_admin_can?(resource, action)
43
+ return false unless respond_to?(:current_admin_user) && current_admin_user
44
+
45
+ current_admin_user.can?(resource, action)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ module BrandingHelper
6
+ # Returns the resolved app name from settings.
7
+ #
8
+ # @return [String] the admin panel title
9
+ def rsb_admin_app_name
10
+ RSB::Settings.get('admin.app_name').to_s.presence || 'Admin'
11
+ end
12
+
13
+ # Returns the resolved logo URL from settings.
14
+ # Empty string means "not set."
15
+ #
16
+ # @return [String] the logo URL or empty string
17
+ def rsb_admin_logo_url
18
+ RSB::Settings.get('admin.logo_url').to_s
19
+ end
20
+
21
+ # Returns the resolved company name from settings.
22
+ # Empty string means "not set."
23
+ #
24
+ # @return [String] the company name or empty string
25
+ def rsb_admin_company_name
26
+ RSB::Settings.get('admin.company_name').to_s
27
+ end
28
+
29
+ # Returns the resolved footer text from settings.
30
+ # Empty string means "not set."
31
+ #
32
+ # @return [String] the footer text or empty string
33
+ def rsb_admin_footer_text
34
+ RSB::Settings.get('admin.footer_text').to_s
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper methods for formatting values and rendering badges in admin panel views.
6
+ #
7
+ # This helper provides value formatting utilities for displaying data in tables,
8
+ # forms, and detail views. It includes badge rendering with auto-detected variants
9
+ # and support for multiple formatter types (datetime, json, truncate, custom procs).
10
+ # The helper is automatically included in all RSB::Admin controllers via the
11
+ # AdminController base class.
12
+ #
13
+ # @example Badge rendering
14
+ # <%= rsb_admin_badge("Active", variant: :success) %>
15
+ # <%= rsb_admin_badge(user.status, variant: :info) %>
16
+ #
17
+ # @example Value formatting
18
+ # <%= rsb_admin_format_value(user.status, :badge) %>
19
+ # <%= rsb_admin_format_value(user.created_at, :datetime) %>
20
+ # <%= rsb_admin_format_value(user.metadata, :json) %>
21
+ module FormattingHelper
22
+ # Render a badge span with auto-detected or explicit variant.
23
+ #
24
+ # Badges are styled spans with background and text colors corresponding to
25
+ # semantic variants (success, warning, danger, info). The badge uses Tailwind
26
+ # CSS utility classes with rsb-* prefixed custom color tokens.
27
+ #
28
+ # @param text [String, #to_s] The badge text content
29
+ # @param variant [Symbol] The badge variant (:success, :warning, :danger, :info)
30
+ # Defaults to :info
31
+ #
32
+ # @return [ActiveSupport::SafeBuffer] HTML-safe badge span element
33
+ #
34
+ # @example Success badge
35
+ # rsb_admin_badge("Active", variant: :success)
36
+ # # => '<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-rsb-success-bg text-rsb-success-text">Active</span>'
37
+ #
38
+ # @example Warning badge
39
+ # rsb_admin_badge("Pending", variant: :warning)
40
+ # # => '<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-rsb-warning-bg text-rsb-warning-text">Pending</span>'
41
+ #
42
+ # @example Danger badge
43
+ # rsb_admin_badge("Expired", variant: :danger)
44
+ # # => '<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-rsb-danger-bg text-rsb-danger-text">Expired</span>'
45
+ #
46
+ # @example Info badge (default)
47
+ # rsb_admin_badge("Unknown")
48
+ # # => '<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-rsb-info-bg text-rsb-info-text">Unknown</span>'
49
+ def rsb_admin_badge(text, variant: :info)
50
+ variant_class = case variant.to_s
51
+ when 'success' then 'bg-rsb-success-bg text-rsb-success-text'
52
+ when 'warning' then 'bg-rsb-warning-bg text-rsb-warning-text'
53
+ when 'danger' then 'bg-rsb-danger-bg text-rsb-danger-text'
54
+ else 'bg-rsb-info-bg text-rsb-info-text'
55
+ end
56
+ content_tag(:span, text, class: "inline-block px-2 py-0.5 rounded-full text-xs font-medium #{variant_class}")
57
+ end
58
+
59
+ # Format a value using a formatter strategy.
60
+ #
61
+ # This method provides a unified interface for formatting values in admin views.
62
+ # It supports built-in formatters (:badge, :datetime, :truncate, :json), custom
63
+ # proc formatters, and intelligent defaults when no formatter is specified.
64
+ # Returns HTML-safe strings suitable for direct rendering in views.
65
+ #
66
+ # @param value [Object] The value to format (can be nil, String, Time, Hash, Array, etc.)
67
+ # @param formatter [Symbol, Proc, nil] The formatter to use:
68
+ # - :badge — renders as badge with auto-detected variant (rule #4)
69
+ # - :datetime — formats Time values as "Month DD, YYYY at HH:MM PM"
70
+ # - :truncate — truncates to 50 chars with ellipsis
71
+ # - :json — pretty-prints Hash/Array as <pre> block
72
+ # - Proc — calls proc with (value) or (value, record) based on arity
73
+ # - nil — auto-formats based on value type
74
+ # @param record [Object, nil] Optional record passed to proc formatters with arity 2
75
+ #
76
+ # @return [ActiveSupport::SafeBuffer, String] HTML-safe formatted value
77
+ #
78
+ # @example Badge with auto-detection (rule #4)
79
+ # rsb_admin_format_value("active", :badge)
80
+ # # => '<span class="...bg-rsb-success-bg...">Active</span>'
81
+ # rsb_admin_format_value("suspended", :badge)
82
+ # # => '<span class="...bg-rsb-warning-bg...">Suspended</span>'
83
+ #
84
+ # @example Datetime formatting
85
+ # rsb_admin_format_value(Time.new(2024, 6, 15, 14, 30), :datetime)
86
+ # # => "June 15, 2024 at 02:30 PM"
87
+ #
88
+ # @example Truncate long text
89
+ # rsb_admin_format_value("a" * 100, :truncate)
90
+ # # => "aaaaaaaaaa... (truncated)"
91
+ #
92
+ # @example JSON formatting
93
+ # rsb_admin_format_value({ foo: "bar", baz: 123 }, :json)
94
+ # # => '<pre class="...">{\n "foo": "bar",\n "baz": 123\n}</pre>'
95
+ #
96
+ # @example Empty JSON
97
+ # rsb_admin_format_value({}, :json)
98
+ # # => '<span class="text-rsb-muted">Empty</span>'
99
+ #
100
+ # @example Custom proc formatter
101
+ # formatter = ->(val) { "USD #{val}" }
102
+ # rsb_admin_format_value(100, formatter)
103
+ # # => "USD 100"
104
+ #
105
+ # @example Proc with record
106
+ # formatter = ->(val, rec) { "#{val} for #{rec.name}" }
107
+ # rsb_admin_format_value("admin", formatter, user)
108
+ # # => "admin for John Doe"
109
+ #
110
+ # @example Nil value
111
+ # rsb_admin_format_value(nil, nil)
112
+ # # => '<span class="text-rsb-muted">-</span>'
113
+ #
114
+ # @example XSS prevention
115
+ # rsb_admin_format_value("<script>alert('xss')</script>", nil)
116
+ # # => "&lt;script&gt;alert('xss')&lt;/script&gt;"
117
+ def rsb_admin_format_value(value, formatter, record = nil)
118
+ return content_tag(:span, '-', class: 'text-rsb-muted') if value.nil?
119
+
120
+ case formatter
121
+ when :badge
122
+ variant = auto_badge_variant(value)
123
+ rsb_admin_badge(value.to_s.titleize, variant: variant)
124
+ when :datetime
125
+ if value.respond_to?(:strftime)
126
+ value.strftime('%B %d, %Y at %I:%M %p')
127
+ else
128
+ value.to_s
129
+ end
130
+ when :truncate
131
+ truncate(value.to_s, length: 50)
132
+ when :json
133
+ if value.is_a?(Hash) || value.is_a?(Array)
134
+ if value.empty?
135
+ content_tag(:span, 'Empty', class: 'text-rsb-muted')
136
+ else
137
+ content_tag(:pre, JSON.pretty_generate(value),
138
+ class: 'mt-1 p-3 bg-rsb-bg rounded-rsb text-xs font-mono overflow-x-auto whitespace-pre')
139
+ end
140
+ else
141
+ value.to_s
142
+ end
143
+ when Proc
144
+ result = formatter.arity == 2 ? formatter.call(value, record) : formatter.call(value)
145
+ result.to_s
146
+ when nil
147
+ # No formatter — render as-is, with special handling for known types
148
+ if (value.is_a?(Hash) || value.is_a?(Array)) && value.any?
149
+ content_tag(:pre, JSON.pretty_generate(value),
150
+ class: 'mt-1 p-3 bg-rsb-bg rounded-rsb text-xs font-mono overflow-x-auto whitespace-pre')
151
+ elsif value.is_a?(Time)
152
+ value.strftime('%B %d, %Y at %I:%M %p')
153
+ else
154
+ ERB::Util.html_escape(value.to_s)
155
+ end
156
+ else
157
+ ERB::Util.html_escape(value.to_s)
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ # Auto-detect badge variant from status-like values (rule #4).
164
+ #
165
+ # This method implements the badge auto-detection logic for common status
166
+ # strings. It performs case-insensitive matching against predefined status
167
+ # categories and returns the corresponding semantic variant.
168
+ #
169
+ # @param value [#to_s] The value to analyze (typically a status string)
170
+ #
171
+ # @return [Symbol] The detected variant (:success, :warning, :danger, or :info)
172
+ #
173
+ # @example Success states
174
+ # auto_badge_variant("active") # => :success
175
+ # auto_badge_variant("ENABLED") # => :success
176
+ # auto_badge_variant("confirmed") # => :success
177
+ #
178
+ # @example Warning states
179
+ # auto_badge_variant("pending") # => :warning
180
+ # auto_badge_variant("SUSPENDED") # => :warning
181
+ # auto_badge_variant("invited") # => :warning
182
+ #
183
+ # @example Danger states
184
+ # auto_badge_variant("expired") # => :danger
185
+ # auto_badge_variant("DELETED") # => :danger
186
+ # auto_badge_variant("banned") # => :danger
187
+ #
188
+ # @example Unknown states (fallback)
189
+ # auto_badge_variant("unknown") # => :info
190
+ # auto_badge_variant("custom") # => :info
191
+ def auto_badge_variant(value)
192
+ case value.to_s.downcase
193
+ when 'active', 'enabled', 'confirmed', 'accepted'
194
+ :success
195
+ when 'suspended', 'pending', 'invited', 'expiring'
196
+ :warning
197
+ when 'deactivated', 'disabled', 'expired', 'revoked', 'banned', 'deleted'
198
+ :danger
199
+ else
200
+ :info
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end