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,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