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,386 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class ResourcesController < AdminController
|
|
6
|
+
prepend_before_action :resolve_registry_entry
|
|
7
|
+
before_action :authorize_resource
|
|
8
|
+
|
|
9
|
+
# Display the index page for a resource or page.
|
|
10
|
+
#
|
|
11
|
+
# Behavior depends on the type of entry:
|
|
12
|
+
# - If it's a page, delegates to the page's controller
|
|
13
|
+
# - If it's a resource with a custom controller, delegates to that controller
|
|
14
|
+
# - Otherwise, loads records with filtering, sorting, and pagination
|
|
15
|
+
#
|
|
16
|
+
# Filtering is applied from params[:q] using registered FilterDefinitions.
|
|
17
|
+
# Sorting is applied from params[:sort] and params[:dir], with fallback to default_sort.
|
|
18
|
+
# Pagination uses per_page from registration or global config, with 1-based page numbers.
|
|
19
|
+
#
|
|
20
|
+
# @return [void]
|
|
21
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the index action
|
|
22
|
+
def index
|
|
23
|
+
if @page
|
|
24
|
+
dispatch_to_page_controller(:index)
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
return dispatch_to_custom_controller(:index) if @registration&.custom_controller?
|
|
29
|
+
|
|
30
|
+
authorize_resource
|
|
31
|
+
|
|
32
|
+
scope = @registration.model_class.all
|
|
33
|
+
|
|
34
|
+
# Apply filters (rule #3: only if registration has filters)
|
|
35
|
+
if @registration.filters&.any? && params[:q].present?
|
|
36
|
+
@registration.filters.each do |filter|
|
|
37
|
+
value = params[:q][filter.key.to_s]
|
|
38
|
+
scope = filter.apply(scope, value)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Apply sorting (rule #11)
|
|
43
|
+
sort_column = params[:sort]
|
|
44
|
+
sort_direction = params[:dir]&.downcase == 'desc' ? 'DESC' : 'ASC'
|
|
45
|
+
|
|
46
|
+
scope = if sort_column.present? && @registration.columns&.any? { |c| c.key.to_s == sort_column && c.sortable }
|
|
47
|
+
scope.order(Arel.sql("#{sort_column} #{sort_direction}"))
|
|
48
|
+
elsif @registration.default_sort
|
|
49
|
+
scope.order(@registration.default_sort[:column] => @registration.default_sort[:direction])
|
|
50
|
+
else
|
|
51
|
+
scope.order(id: :desc)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Pagination (rule #10)
|
|
55
|
+
per_page = @registration.per_page || RSB::Admin.configuration.per_page
|
|
56
|
+
@current_page = [params[:page].to_i, 1].max
|
|
57
|
+
@total_count = scope.count
|
|
58
|
+
@total_pages = (@total_count.to_f / per_page).ceil
|
|
59
|
+
@total_pages = 1 if @total_pages < 1
|
|
60
|
+
@records = scope.limit(per_page).offset((@current_page - 1) * per_page)
|
|
61
|
+
|
|
62
|
+
# Store filter values for view
|
|
63
|
+
@filter_values = params[:q] || {}
|
|
64
|
+
|
|
65
|
+
# Set page title
|
|
66
|
+
@rsb_page_title = @registration.label
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Display the show page for a specific resource record.
|
|
70
|
+
#
|
|
71
|
+
# If the resource has a custom controller, delegates to it.
|
|
72
|
+
# Otherwise, loads the record and renders the generic show view.
|
|
73
|
+
# The view uses @registration.show_columns to determine which fields to display.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the show action
|
|
77
|
+
# @raise [ActiveRecord::RecordNotFound] if the record doesn't exist
|
|
78
|
+
def show
|
|
79
|
+
return dispatch_to_custom_controller(:show) if @registration&.custom_controller?
|
|
80
|
+
|
|
81
|
+
authorize_resource
|
|
82
|
+
@record = @registration.model_class.find(params[:id])
|
|
83
|
+
@rsb_page_title = "#{@registration.label.singularize} ##{@record.id}"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Display the new page for creating a resource record.
|
|
87
|
+
#
|
|
88
|
+
# If the resource has a custom controller, delegates to it.
|
|
89
|
+
# Otherwise, instantiates a new record and renders the generic new view.
|
|
90
|
+
# The view uses @registration.new_form_fields to determine which fields to display.
|
|
91
|
+
#
|
|
92
|
+
# @return [void]
|
|
93
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the new action
|
|
94
|
+
def new
|
|
95
|
+
return dispatch_to_custom_controller(:new) if @registration&.custom_controller?
|
|
96
|
+
|
|
97
|
+
authorize_resource
|
|
98
|
+
@record = @registration.model_class.new
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Create a new resource record.
|
|
102
|
+
#
|
|
103
|
+
# If the resource has a custom controller, delegates to it.
|
|
104
|
+
# Otherwise, creates a new record with the submitted params and either
|
|
105
|
+
# redirects to the show page on success or re-renders the form on failure.
|
|
106
|
+
# Flash message is localized using i18n key "rsb.admin.resources.created".
|
|
107
|
+
#
|
|
108
|
+
# @return [void]
|
|
109
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the create action
|
|
110
|
+
def create
|
|
111
|
+
return dispatch_to_custom_controller(:create) if @registration&.custom_controller?
|
|
112
|
+
|
|
113
|
+
authorize_resource
|
|
114
|
+
@record = @registration.model_class.new(resource_params)
|
|
115
|
+
if @record.save
|
|
116
|
+
redirect_to rsb_admin_resource_show_path(@registration.route_key, @record.id),
|
|
117
|
+
notice: I18n.t('rsb.admin.resources.created', resource: @registration.label.singularize)
|
|
118
|
+
else
|
|
119
|
+
render :new, status: :unprocessable_entity
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Display the edit page for a resource record.
|
|
124
|
+
#
|
|
125
|
+
# If the resource has a custom controller, delegates to it.
|
|
126
|
+
# Otherwise, loads the record and renders the generic edit view.
|
|
127
|
+
# The view uses @registration.edit_form_fields to determine which fields to display.
|
|
128
|
+
#
|
|
129
|
+
# @return [void]
|
|
130
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the edit action
|
|
131
|
+
# @raise [ActiveRecord::RecordNotFound] if the record doesn't exist
|
|
132
|
+
def edit
|
|
133
|
+
return dispatch_to_custom_controller(:edit) if @registration&.custom_controller?
|
|
134
|
+
|
|
135
|
+
authorize_resource
|
|
136
|
+
@record = @registration.model_class.find(params[:id])
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Update a resource record.
|
|
140
|
+
#
|
|
141
|
+
# If the resource has a custom controller, delegates to it.
|
|
142
|
+
# Otherwise, updates the record with the submitted params and either
|
|
143
|
+
# redirects to the show page on success or re-renders the edit form on failure.
|
|
144
|
+
# Flash message is localized using i18n key "rsb.admin.resources.updated".
|
|
145
|
+
#
|
|
146
|
+
# @return [void]
|
|
147
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the update action
|
|
148
|
+
# @raise [ActiveRecord::RecordNotFound] if the record doesn't exist
|
|
149
|
+
def update
|
|
150
|
+
return dispatch_to_custom_controller(:update) if @registration&.custom_controller?
|
|
151
|
+
|
|
152
|
+
authorize_resource
|
|
153
|
+
@record = @registration.model_class.find(params[:id])
|
|
154
|
+
if @record.update(resource_params)
|
|
155
|
+
redirect_to rsb_admin_resource_show_path(@registration.route_key, @record.id),
|
|
156
|
+
notice: I18n.t('rsb.admin.resources.updated', resource: @registration.label.singularize)
|
|
157
|
+
else
|
|
158
|
+
render :edit, status: :unprocessable_entity
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Delete a resource record.
|
|
163
|
+
#
|
|
164
|
+
# If the resource has a custom controller, delegates to it.
|
|
165
|
+
# Otherwise, destroys the record and redirects to the index.
|
|
166
|
+
# Flash message is localized using i18n key "rsb.admin.resources.deleted".
|
|
167
|
+
#
|
|
168
|
+
# @return [void]
|
|
169
|
+
# @raise [ActionController::RoutingError] if the resource doesn't support the destroy action
|
|
170
|
+
# @raise [ActiveRecord::RecordNotFound] if the record doesn't exist
|
|
171
|
+
def destroy
|
|
172
|
+
if @page
|
|
173
|
+
dispatch_to_page_controller(:destroy)
|
|
174
|
+
elsif @registration&.custom_controller?
|
|
175
|
+
dispatch_to_custom_controller(:destroy)
|
|
176
|
+
elsif @registration
|
|
177
|
+
authorize_resource
|
|
178
|
+
@record = @registration.model_class.find(params[:id])
|
|
179
|
+
@record.destroy!
|
|
180
|
+
redirect_to rsb_admin_resource_path(@registration.route_key),
|
|
181
|
+
notice: I18n.t('rsb.admin.resources.deleted', resource: @registration.label.singularize)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Generic fallback for pages without a controller.
|
|
186
|
+
#
|
|
187
|
+
# @return [void]
|
|
188
|
+
def page
|
|
189
|
+
# Renders the generic page view
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Handle static page sub-actions.
|
|
193
|
+
#
|
|
194
|
+
# Routes like `/admin/dashboard/export` are dispatched to the page's
|
|
195
|
+
# controller with the action key. Returns 404 if the page or action
|
|
196
|
+
# is not found.
|
|
197
|
+
#
|
|
198
|
+
# @return [void]
|
|
199
|
+
# @raise [ActionController::RoutingError] if the page or action doesn't exist
|
|
200
|
+
def page_action
|
|
201
|
+
@page = RSB::Admin.registry.find_page_by_key(params[:resource_key])
|
|
202
|
+
|
|
203
|
+
unless @page
|
|
204
|
+
head :not_found
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
authorize_admin_action!(resource: @page.key.to_s, action: params[:action_key])
|
|
209
|
+
|
|
210
|
+
action = @page.find_action(params[:action_key])
|
|
211
|
+
unless action
|
|
212
|
+
head :not_found
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Build page breadcrumbs and pass to dispatched controller
|
|
217
|
+
build_page_action_breadcrumbs
|
|
218
|
+
request.env['rsb.admin.breadcrumbs'] = @breadcrumbs
|
|
219
|
+
|
|
220
|
+
# Dispatch to the page's controller with the action key
|
|
221
|
+
controller_name = @page.controller
|
|
222
|
+
controller_class_name = "#{controller_name}_controller".classify
|
|
223
|
+
controller_class = controller_class_name.constantize
|
|
224
|
+
dispatch_action = params[:action_key].to_sym
|
|
225
|
+
|
|
226
|
+
status, headers, body = controller_class.action(dispatch_action).call(request.env)
|
|
227
|
+
self.status = status
|
|
228
|
+
self.response_body = body
|
|
229
|
+
headers.each { |k, v| response.headers[k] = v }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Handle custom member actions for resources with custom controllers.
|
|
233
|
+
#
|
|
234
|
+
# Custom actions are PATCH routes like `/admin/identities/:id/suspend`.
|
|
235
|
+
# The action name is extracted from params and delegated to the resource's
|
|
236
|
+
# custom controller if it exists and the action is registered.
|
|
237
|
+
#
|
|
238
|
+
# @return [void]
|
|
239
|
+
# @raise [ActionController::RoutingError] if no custom controller is configured
|
|
240
|
+
# or the action is not registered for this resource
|
|
241
|
+
def custom_action
|
|
242
|
+
action = params[:custom_action].to_sym
|
|
243
|
+
unless @registration&.custom_controller? && @registration.action?(action)
|
|
244
|
+
raise ActionController::RoutingError, 'Not Found'
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
dispatch_to_custom_controller(action)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
# Builds breadcrumbs for dynamic resource and page routes.
|
|
253
|
+
#
|
|
254
|
+
# For resources: AppName > Category > Resource Label > #ID (if show/edit) > New/Edit
|
|
255
|
+
# For pages: AppName > Category > Page Label
|
|
256
|
+
#
|
|
257
|
+
# Uses prepend_before_action :resolve_registry_entry to ensure @registration
|
|
258
|
+
# and @page are set before this runs (ahead of inherited AdminController callbacks).
|
|
259
|
+
#
|
|
260
|
+
# @return [void]
|
|
261
|
+
def build_breadcrumbs
|
|
262
|
+
super
|
|
263
|
+
if @registration
|
|
264
|
+
add_breadcrumb(@registration.category_name)
|
|
265
|
+
add_breadcrumb(@registration.label, rsb_admin_resource_path(@registration.route_key))
|
|
266
|
+
if params[:id].present?
|
|
267
|
+
add_breadcrumb("##{params[:id]}",
|
|
268
|
+
rsb_admin_resource_show_path(@registration.route_key, params[:id]))
|
|
269
|
+
end
|
|
270
|
+
if action_name.in?(%w[new create])
|
|
271
|
+
add_breadcrumb(I18n.t('rsb.admin.shared.new', resource: @registration.label.singularize))
|
|
272
|
+
end
|
|
273
|
+
add_breadcrumb(I18n.t('rsb.admin.shared.edit')) if action_name.in?(%w[edit update])
|
|
274
|
+
elsif @page
|
|
275
|
+
add_breadcrumb(@page.category_name)
|
|
276
|
+
add_breadcrumb(@page.label)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Builds breadcrumbs for page sub-actions dispatched via page_action.
|
|
281
|
+
#
|
|
282
|
+
# Since page_action bypasses the normal build_breadcrumbs flow (it's a
|
|
283
|
+
# separate action on ResourcesController), we manually construct the
|
|
284
|
+
# breadcrumb trail: Root > Category > Page Label
|
|
285
|
+
#
|
|
286
|
+
# The page label includes a path since sub-actions may append additional
|
|
287
|
+
# breadcrumb items after it.
|
|
288
|
+
#
|
|
289
|
+
# @return [void]
|
|
290
|
+
def build_page_action_breadcrumbs
|
|
291
|
+
@breadcrumbs = [
|
|
292
|
+
RSB::Admin::BreadcrumbItem.new(
|
|
293
|
+
label: RSB::Settings.get('admin.app_name').to_s.presence || RSB::Admin.configuration.app_name,
|
|
294
|
+
path: rsb_admin.dashboard_path
|
|
295
|
+
)
|
|
296
|
+
]
|
|
297
|
+
add_breadcrumb(@page.category_name)
|
|
298
|
+
add_breadcrumb(@page.label, rsb_admin_page_path(@page.key))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def resolve_registry_entry
|
|
302
|
+
@registration = RSB::Admin.registry.find_resource_by_route_key(params[:resource_key])
|
|
303
|
+
|
|
304
|
+
return if @registration
|
|
305
|
+
|
|
306
|
+
@page = RSB::Admin.registry.find_page_by_key(params[:resource_key])
|
|
307
|
+
raise ActionController::RoutingError, 'Not Found' unless @page
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def authorize_resource
|
|
311
|
+
resource_name = @page ? @page.key.to_s : params[:resource_key]
|
|
312
|
+
action = if action_name == 'custom_action' && params[:custom_action].present?
|
|
313
|
+
params[:custom_action]
|
|
314
|
+
else
|
|
315
|
+
action_name
|
|
316
|
+
end
|
|
317
|
+
authorize_admin_action!(resource: resource_name, action: action)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Dispatch a request to a page's custom controller.
|
|
321
|
+
#
|
|
322
|
+
# Uses the Rack interface to invoke the controller action and copy the
|
|
323
|
+
# response (status, headers, body) into the current controller's response.
|
|
324
|
+
# Falls back to rendering the generic page view if the controller doesn't exist.
|
|
325
|
+
#
|
|
326
|
+
# @param action [Symbol] the action to invoke (default: :index)
|
|
327
|
+
# @return [void]
|
|
328
|
+
def dispatch_to_page_controller(action = :index)
|
|
329
|
+
controller_name = @page.controller
|
|
330
|
+
controller_class_name = "#{controller_name}_controller".classify
|
|
331
|
+
|
|
332
|
+
begin
|
|
333
|
+
controller_class = controller_class_name.constantize
|
|
334
|
+
# Pass breadcrumb context to the dispatched controller
|
|
335
|
+
request.env['rsb.admin.breadcrumbs'] = @breadcrumbs
|
|
336
|
+
status, headers, body = controller_class.action(action).call(request.env)
|
|
337
|
+
self.status = status
|
|
338
|
+
self.response_body = body
|
|
339
|
+
headers.each { |k, v| response.headers[k] = v }
|
|
340
|
+
rescue NameError
|
|
341
|
+
# Controller class doesn't exist, render generic fallback
|
|
342
|
+
render :page
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Dispatch a request to a resource's custom controller.
|
|
347
|
+
#
|
|
348
|
+
# Uses the Rack interface to invoke the controller action and copy the
|
|
349
|
+
# response (status, headers, body) into the current controller's response.
|
|
350
|
+
# This allows resources to have dedicated controllers while maintaining
|
|
351
|
+
# a single routing structure.
|
|
352
|
+
#
|
|
353
|
+
# @param action [Symbol] the action to invoke
|
|
354
|
+
# @return [void]
|
|
355
|
+
# @raise [NameError] if the controller class doesn't exist
|
|
356
|
+
def dispatch_to_custom_controller(action)
|
|
357
|
+
controller_name = @registration.controller
|
|
358
|
+
controller_class_name = "#{controller_name}_controller".classify
|
|
359
|
+
controller_class = controller_class_name.constantize
|
|
360
|
+
# Pass breadcrumb context to the dispatched controller
|
|
361
|
+
request.env['rsb.admin.breadcrumbs'] = @breadcrumbs
|
|
362
|
+
status, headers, body = controller_class.action(action).call(request.env)
|
|
363
|
+
self.status = status
|
|
364
|
+
self.response_body = body
|
|
365
|
+
headers.each { |k, v| response.headers[k] = v }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Extract permitted params for the resource model.
|
|
369
|
+
#
|
|
370
|
+
# Uses form_fields from registration when available, otherwise auto-detects
|
|
371
|
+
# editable columns from the model's schema (excluding sensitive columns,
|
|
372
|
+
# ID, and timestamps).
|
|
373
|
+
#
|
|
374
|
+
# @return [ActionController::Parameters] the permitted parameters
|
|
375
|
+
def resource_params
|
|
376
|
+
if @registration.form_fields
|
|
377
|
+
permitted_keys = @registration.form_fields.map(&:key)
|
|
378
|
+
else
|
|
379
|
+
# Fallback: auto-detect (existing behavior)
|
|
380
|
+
permitted_keys = @registration.model_class.column_names - ResourceRegistration::SENSITIVE_COLUMNS - ResourceRegistration::SKIP_FORM_COLUMNS
|
|
381
|
+
end
|
|
382
|
+
params.require(@registration.model_class.model_name.param_key).permit(*permitted_keys)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class RolesController < AdminController
|
|
6
|
+
before_action :authorize_roles
|
|
7
|
+
before_action :set_role, only: %i[show edit update destroy]
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@rsb_page_title = I18n.t('rsb.admin.roles.index.page_title', default: 'Roles')
|
|
11
|
+
@roles = Role.all.order(:name)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@registry = RSB::Admin.registry
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def new
|
|
19
|
+
@role = Role.new(permissions: {})
|
|
20
|
+
@registry = RSB::Admin.registry
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create
|
|
24
|
+
@role = Role.new(role_params)
|
|
25
|
+
@registry = RSB::Admin.registry
|
|
26
|
+
if @role.save
|
|
27
|
+
redirect_to rsb_admin.role_path(@role), notice: 'Role created.'
|
|
28
|
+
else
|
|
29
|
+
render :new, status: :unprocessable_entity
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def edit
|
|
34
|
+
@registry = RSB::Admin.registry
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update
|
|
38
|
+
@registry = RSB::Admin.registry
|
|
39
|
+
if @role.update(role_params)
|
|
40
|
+
redirect_to rsb_admin.role_path(@role), notice: 'Role updated.'
|
|
41
|
+
else
|
|
42
|
+
render :edit, status: :unprocessable_entity
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def destroy
|
|
47
|
+
if @role.destroy
|
|
48
|
+
redirect_to rsb_admin.roles_path, notice: 'Role deleted.'
|
|
49
|
+
else
|
|
50
|
+
redirect_to rsb_admin.roles_path, alert: "Cannot delete role: #{@role.errors.full_messages.join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Builds breadcrumbs for role management pages.
|
|
57
|
+
# Dashboard > System > Roles > #ID (if applicable) > New/Edit
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
def build_breadcrumbs
|
|
61
|
+
super
|
|
62
|
+
add_breadcrumb(I18n.t('rsb.admin.shared.system'))
|
|
63
|
+
add_breadcrumb(I18n.t('rsb.admin.roles.title'), rsb_admin.roles_path)
|
|
64
|
+
add_breadcrumb("##{params[:id]}", rsb_admin.role_path(params[:id])) if params[:id].present?
|
|
65
|
+
add_breadcrumb(I18n.t('rsb.admin.shared.new', resource: 'Role')) if action_name.in?(%w[new create])
|
|
66
|
+
return unless action_name.in?(%w[edit update])
|
|
67
|
+
|
|
68
|
+
add_breadcrumb(I18n.t('rsb.admin.shared.edit'))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def set_role
|
|
72
|
+
@role = Role.find(params[:id])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def role_params
|
|
76
|
+
permitted = params.require(:role).permit(
|
|
77
|
+
:name,
|
|
78
|
+
:permissions_json,
|
|
79
|
+
:superadmin_toggle,
|
|
80
|
+
permissions_checkboxes: {}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Rails strong params cannot cleanly permit a hash-of-arrays,
|
|
84
|
+
# so we manually extract permissions_checkboxes
|
|
85
|
+
if params[:role][:permissions_checkboxes].present?
|
|
86
|
+
permitted[:permissions_checkboxes] = params[:role][:permissions_checkboxes]
|
|
87
|
+
.to_unsafe_h
|
|
88
|
+
.transform_values { |v| Array(v) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
permitted
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def authorize_roles
|
|
95
|
+
authorize_admin_action!(resource: 'roles', action: action_name)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class SessionsController < ActionController::Base
|
|
6
|
+
layout 'rsb/admin/application'
|
|
7
|
+
|
|
8
|
+
helper RSB::Admin::BrandingHelper
|
|
9
|
+
helper RSB::Settings::LocaleHelper
|
|
10
|
+
include RSB::Settings::LocaleHelper
|
|
11
|
+
helper RSB::Settings::SeoHelper
|
|
12
|
+
include RSB::Settings::SeoHelper
|
|
13
|
+
helper_method :current_admin_user
|
|
14
|
+
|
|
15
|
+
before_action :set_seo_context
|
|
16
|
+
before_action :check_admin_enabled
|
|
17
|
+
before_action :redirect_if_signed_in, only: [:new]
|
|
18
|
+
|
|
19
|
+
def new
|
|
20
|
+
@rsb_page_title = I18n.t('rsb.admin.sessions.new.page_title', default: 'Admin Sign In')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create
|
|
24
|
+
admin = AdminUser.find_by(email: params[:email])
|
|
25
|
+
if admin&.authenticate(params[:password])
|
|
26
|
+
if admin.otp_enabled?
|
|
27
|
+
# Store pending state — admin must complete 2FA
|
|
28
|
+
session[:rsb_admin_pending_user_id] = admin.id
|
|
29
|
+
session[:rsb_admin_pending_at] = Time.current.to_i
|
|
30
|
+
session[:rsb_admin_2fa_attempts] = 0
|
|
31
|
+
redirect_to rsb_admin.two_factor_login_path
|
|
32
|
+
elsif ActiveModel::Type::Boolean.new.cast(RSB::Settings.get('admin.require_two_factor'))
|
|
33
|
+
# Check if force 2FA is enabled
|
|
34
|
+
admin_session = AdminSession.create_from_request!(admin_user: admin, request: request)
|
|
35
|
+
session[:rsb_admin_session_token] = admin_session.session_token
|
|
36
|
+
admin.record_sign_in!(ip: request.remote_ip)
|
|
37
|
+
redirect_to rsb_admin.new_profile_two_factor_path,
|
|
38
|
+
alert: 'Two-factor authentication is required. Please set up 2FA to continue.'
|
|
39
|
+
# Create session but redirect to enrollment
|
|
40
|
+
else
|
|
41
|
+
complete_sign_in!(admin)
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
@email = params[:email]
|
|
45
|
+
flash.now[:alert] = 'Invalid email or password.'
|
|
46
|
+
render :new, status: :unprocessable_entity
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def two_factor
|
|
51
|
+
return if valid_pending_session?
|
|
52
|
+
|
|
53
|
+
redirect_to rsb_admin.login_path, alert: pending_expired? ? 'Session expired. Please sign in again.' : nil
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def verify_two_factor
|
|
58
|
+
unless valid_pending_session?
|
|
59
|
+
redirect_to rsb_admin.login_path, alert: pending_expired? ? 'Session expired. Please sign in again.' : nil
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if session[:rsb_admin_2fa_attempts].to_i >= 5
|
|
64
|
+
clear_pending_session!
|
|
65
|
+
redirect_to rsb_admin.login_path, alert: 'Too many attempts. Please sign in again.'
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
admin = AdminUser.find_by(id: session[:rsb_admin_pending_user_id])
|
|
70
|
+
unless admin
|
|
71
|
+
clear_pending_session!
|
|
72
|
+
redirect_to rsb_admin.login_path
|
|
73
|
+
return
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
if admin.verify_otp(params[:otp_code]) || admin.verify_backup_code(params[:otp_code].to_s)
|
|
77
|
+
clear_pending_session!
|
|
78
|
+
complete_sign_in!(admin)
|
|
79
|
+
else
|
|
80
|
+
session[:rsb_admin_2fa_attempts] = session[:rsb_admin_2fa_attempts].to_i + 1
|
|
81
|
+
flash.now[:alert] = 'Invalid verification code.'
|
|
82
|
+
render :two_factor, status: :unprocessable_entity
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def destroy
|
|
87
|
+
token = session[:rsb_admin_session_token]
|
|
88
|
+
AdminSession.find_by(session_token: token)&.destroy if token
|
|
89
|
+
session.delete(:rsb_admin_session_token)
|
|
90
|
+
redirect_to rsb_admin.login_path, notice: 'Signed out.'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def set_seo_context
|
|
96
|
+
@rsb_seo_context = :admin
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def complete_sign_in!(admin)
|
|
100
|
+
admin_session = AdminSession.create_from_request!(admin_user: admin, request: request)
|
|
101
|
+
session[:rsb_admin_session_token] = admin_session.session_token
|
|
102
|
+
admin.record_sign_in!(ip: request.remote_ip)
|
|
103
|
+
redirect_to rsb_admin.dashboard_path, notice: 'Signed in successfully.'
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def valid_pending_session?
|
|
107
|
+
session[:rsb_admin_pending_user_id].present? && !pending_expired?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def pending_expired?
|
|
111
|
+
pending_at = session[:rsb_admin_pending_at].to_i
|
|
112
|
+
pending_at.positive? && Time.at(pending_at) < 5.minutes.ago
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def clear_pending_session!
|
|
116
|
+
session.delete(:rsb_admin_pending_user_id)
|
|
117
|
+
session.delete(:rsb_admin_pending_at)
|
|
118
|
+
session.delete(:rsb_admin_2fa_attempts)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def check_admin_enabled
|
|
122
|
+
return if RSB::Admin.enabled?
|
|
123
|
+
|
|
124
|
+
render template: 'rsb/admin/shared/disabled', layout: false, status: :service_unavailable
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def redirect_if_signed_in
|
|
128
|
+
redirect_to rsb_admin.dashboard_path if current_admin_user
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def current_admin_user
|
|
132
|
+
return @current_admin_user if defined?(@current_admin_user)
|
|
133
|
+
|
|
134
|
+
token = session[:rsb_admin_session_token]
|
|
135
|
+
@current_admin_user = token ? AdminSession.find_by(session_token: token)&.admin_user : nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|