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.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +83 -0
- data/Rakefile +25 -0
- data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
- data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
- data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
- data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
- data/app/controllers/rsb/admin/admin_controller.rb +138 -0
- data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
- data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
- data/app/controllers/rsb/admin/profile_controller.rb +146 -0
- data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
- data/app/controllers/rsb/admin/resources_controller.rb +386 -0
- data/app/controllers/rsb/admin/roles_controller.rb +99 -0
- data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
- data/app/controllers/rsb/admin/settings_controller.rb +203 -0
- data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
- data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
- data/app/helpers/rsb/admin/branding_helper.rb +38 -0
- data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
- data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
- data/app/helpers/rsb/admin/icons_helper.rb +55 -0
- data/app/helpers/rsb/admin/table_helper.rb +132 -0
- data/app/helpers/rsb/admin/theme_helper.rb +84 -0
- data/app/helpers/rsb/admin/url_helper.rb +109 -0
- data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
- data/app/models/rsb/admin/admin_session.rb +109 -0
- data/app/models/rsb/admin/admin_user.rb +153 -0
- data/app/models/rsb/admin/application_record.rb +10 -0
- data/app/models/rsb/admin/role.rb +63 -0
- data/app/views/layouts/rsb/admin/application.html.erb +45 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
- data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
- data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
- data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
- data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
- data/app/views/rsb/admin/profile/edit.html.erb +67 -0
- data/app/views/rsb/admin/profile/show.html.erb +155 -0
- data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
- data/app/views/rsb/admin/resources/_form.html.erb +20 -0
- data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
- data/app/views/rsb/admin/resources/_table.html.erb +70 -0
- data/app/views/rsb/admin/resources/edit.html.erb +7 -0
- data/app/views/rsb/admin/resources/index.html.erb +49 -0
- data/app/views/rsb/admin/resources/new.html.erb +7 -0
- data/app/views/rsb/admin/resources/page.html.erb +9 -0
- data/app/views/rsb/admin/resources/show.html.erb +55 -0
- data/app/views/rsb/admin/roles/_form.html.erb +197 -0
- data/app/views/rsb/admin/roles/edit.html.erb +7 -0
- data/app/views/rsb/admin/roles/index.html.erb +71 -0
- data/app/views/rsb/admin/roles/new.html.erb +7 -0
- data/app/views/rsb/admin/roles/show.html.erb +99 -0
- data/app/views/rsb/admin/sessions/new.html.erb +31 -0
- data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
- data/app/views/rsb/admin/settings/_field.html.erb +115 -0
- data/app/views/rsb/admin/settings/index.html.erb +61 -0
- data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
- data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
- data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
- data/app/views/rsb/admin/shared/_header.html.erb +50 -0
- data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
- data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
- data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
- data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
- data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
- data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
- data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
- data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
- data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
- data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
- data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
- data/config/locales/en.yml +140 -0
- data/config/locales/seo.en.yml +21 -0
- data/config/routes.rb +59 -0
- data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
- data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
- data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
- data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
- data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
- data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
- data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
- data/lib/rsb/admin/breadcrumb_item.rb +26 -0
- data/lib/rsb/admin/category_registration.rb +177 -0
- data/lib/rsb/admin/column_definition.rb +89 -0
- data/lib/rsb/admin/configuration.rb +69 -0
- data/lib/rsb/admin/engine.rb +34 -0
- data/lib/rsb/admin/filter_definition.rb +129 -0
- data/lib/rsb/admin/form_field_definition.rb +96 -0
- data/lib/rsb/admin/icons.rb +95 -0
- data/lib/rsb/admin/page_registration.rb +140 -0
- data/lib/rsb/admin/registry.rb +109 -0
- data/lib/rsb/admin/resource_dsl_context.rb +139 -0
- data/lib/rsb/admin/resource_registration.rb +287 -0
- data/lib/rsb/admin/settings_schema.rb +60 -0
- data/lib/rsb/admin/test_kit/helpers.rb +316 -0
- data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
- data/lib/rsb/admin/test_kit.rb +11 -0
- data/lib/rsb/admin/theme_definition.rb +46 -0
- data/lib/rsb/admin/themes/modern.rb +44 -0
- data/lib/rsb/admin/version.rb +9 -0
- data/lib/rsb/admin.rb +177 -0
- data/lib/tasks/rsb/admin_tasks.rake +23 -0
- 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
|
+
# # => "<script>alert('xss')</script>"
|
|
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
|