ruby_cms 0.1.0

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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/.cursor/dhh.mdc +698 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +10 -0
  6. data/README.md +235 -0
  7. data/Rakefile +30 -0
  8. data/app/components/ruby_cms/admin/admin_page/admin_table_content.rb +32 -0
  9. data/app/components/ruby_cms/admin/admin_page.rb +345 -0
  10. data/app/components/ruby_cms/admin/base_component.rb +78 -0
  11. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table.rb +149 -0
  12. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_actions.rb +127 -0
  13. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_body.rb +15 -0
  14. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_cell.rb +41 -0
  15. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_checkbox_head.rb +33 -0
  16. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_delete_modal.rb +174 -0
  17. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header.rb +59 -0
  18. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_header_bar.rb +159 -0
  19. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_pagination.rb +192 -0
  20. data/app/components/ruby_cms/admin/bulk_action_table/bulk_action_table_row.rb +97 -0
  21. data/app/components/ruby_cms/admin/bulk_action_table/bulk_actions.rb +137 -0
  22. data/app/controllers/concerns/ruby_cms/admin_pagination.rb +120 -0
  23. data/app/controllers/concerns/ruby_cms/admin_turbo_table.rb +68 -0
  24. data/app/controllers/concerns/ruby_cms/page_tracking.rb +52 -0
  25. data/app/controllers/concerns/ruby_cms/visitor_error_capture.rb +39 -0
  26. data/app/controllers/ruby_cms/admin/analytics_controller.rb +191 -0
  27. data/app/controllers/ruby_cms/admin/base_controller.rb +105 -0
  28. data/app/controllers/ruby_cms/admin/content_blocks_controller.rb +390 -0
  29. data/app/controllers/ruby_cms/admin/dashboard_controller.rb +50 -0
  30. data/app/controllers/ruby_cms/admin/locale_controller.rb +20 -0
  31. data/app/controllers/ruby_cms/admin/permissions_controller.rb +66 -0
  32. data/app/controllers/ruby_cms/admin/settings_controller.rb +223 -0
  33. data/app/controllers/ruby_cms/admin/user_permissions_controller.rb +59 -0
  34. data/app/controllers/ruby_cms/admin/users_controller.rb +107 -0
  35. data/app/controllers/ruby_cms/admin/visitor_errors_controller.rb +89 -0
  36. data/app/controllers/ruby_cms/admin/visual_editor_controller.rb +322 -0
  37. data/app/controllers/ruby_cms/errors_controller.rb +35 -0
  38. data/app/helpers/ruby_cms/admin/admin_page_helper.rb +21 -0
  39. data/app/helpers/ruby_cms/admin/bulk_action_table_helper.rb +159 -0
  40. data/app/helpers/ruby_cms/application_helper.rb +41 -0
  41. data/app/helpers/ruby_cms/bulk_action_table_helper.rb +151 -0
  42. data/app/helpers/ruby_cms/content_blocks_helper.rb +375 -0
  43. data/app/helpers/ruby_cms/settings_helper.rb +160 -0
  44. data/app/javascript/controllers/ruby_cms/auto_save_preference_controller.js +73 -0
  45. data/app/javascript/controllers/ruby_cms/bulk_action_table_controller.js +553 -0
  46. data/app/javascript/controllers/ruby_cms/clickable_row_controller.js +28 -0
  47. data/app/javascript/controllers/ruby_cms/flash_messages_controller.js +29 -0
  48. data/app/javascript/controllers/ruby_cms/index.js +104 -0
  49. data/app/javascript/controllers/ruby_cms/locale_tabs_controller.js +34 -0
  50. data/app/javascript/controllers/ruby_cms/mobile_menu_controller.js +55 -0
  51. data/app/javascript/controllers/ruby_cms/nav_order_sortable_controller.js +192 -0
  52. data/app/javascript/controllers/ruby_cms/page_preview_controller.js +135 -0
  53. data/app/javascript/controllers/ruby_cms/toggle_controller.js +39 -0
  54. data/app/javascript/controllers/ruby_cms/visual_editor_controller.js +321 -0
  55. data/app/models/concerns/content_block/publishable.rb +54 -0
  56. data/app/models/concerns/content_block/searchable.rb +22 -0
  57. data/app/models/content_block.rb +155 -0
  58. data/app/models/ruby_cms/content_block.rb +8 -0
  59. data/app/models/ruby_cms/permission.rb +28 -0
  60. data/app/models/ruby_cms/permittable.rb +39 -0
  61. data/app/models/ruby_cms/preference.rb +111 -0
  62. data/app/models/ruby_cms/user_permission.rb +12 -0
  63. data/app/models/ruby_cms/visitor_error.rb +109 -0
  64. data/app/services/ruby_cms/analytics/report.rb +362 -0
  65. data/app/services/ruby_cms/security_tracker.rb +92 -0
  66. data/app/views/layouts/ruby_cms/_admin_flash_messages.html.erb +37 -0
  67. data/app/views/layouts/ruby_cms/_admin_sidebar.html.erb +121 -0
  68. data/app/views/layouts/ruby_cms/admin.html.erb +81 -0
  69. data/app/views/layouts/ruby_cms/minimal.html.erb +181 -0
  70. data/app/views/ruby_cms/admin/analytics/index.html.erb +160 -0
  71. data/app/views/ruby_cms/admin/analytics/page_details.html.erb +84 -0
  72. data/app/views/ruby_cms/admin/analytics/partials/_back_button.html.erb +3 -0
  73. data/app/views/ruby_cms/admin/analytics/partials/_browser_device.html.erb +40 -0
  74. data/app/views/ruby_cms/admin/analytics/partials/_daily_activity_chart.html.erb +58 -0
  75. data/app/views/ruby_cms/admin/analytics/partials/_hourly_activity_chart.html.erb +51 -0
  76. data/app/views/ruby_cms/admin/analytics/partials/_recent_activity.html.erb +31 -0
  77. data/app/views/ruby_cms/admin/analytics/partials/_security_alert.html.erb +4 -0
  78. data/app/views/ruby_cms/admin/analytics/partials/_top_referrers.html.erb +21 -0
  79. data/app/views/ruby_cms/admin/analytics/visitor_details.html.erb +125 -0
  80. data/app/views/ruby_cms/admin/content_blocks/_form.html.erb +161 -0
  81. data/app/views/ruby_cms/admin/content_blocks/_row.html.erb +25 -0
  82. data/app/views/ruby_cms/admin/content_blocks/edit.html.erb +17 -0
  83. data/app/views/ruby_cms/admin/content_blocks/index.html.erb +66 -0
  84. data/app/views/ruby_cms/admin/content_blocks/new.html.erb +5 -0
  85. data/app/views/ruby_cms/admin/content_blocks/show.html.erb +110 -0
  86. data/app/views/ruby_cms/admin/dashboard/index.html.erb +198 -0
  87. data/app/views/ruby_cms/admin/permissions/_row.html.erb +11 -0
  88. data/app/views/ruby_cms/admin/permissions/index.html.erb +62 -0
  89. data/app/views/ruby_cms/admin/settings/index.html.erb +220 -0
  90. data/app/views/ruby_cms/admin/shared/_bulk_action_table_index.html.erb +56 -0
  91. data/app/views/ruby_cms/admin/user_permissions/index.html.erb +55 -0
  92. data/app/views/ruby_cms/admin/users/_row.html.erb +14 -0
  93. data/app/views/ruby_cms/admin/users/index.html.erb +70 -0
  94. data/app/views/ruby_cms/admin/visitor_errors/_row.html.erb +35 -0
  95. data/app/views/ruby_cms/admin/visitor_errors/index.html.erb +57 -0
  96. data/app/views/ruby_cms/admin/visitor_errors/show.html.erb +147 -0
  97. data/app/views/ruby_cms/admin/visual_editor/index.html.erb +144 -0
  98. data/app/views/ruby_cms/errors/not_found.html.erb +92 -0
  99. data/config/database.yml +6 -0
  100. data/config/importmap.rb +36 -0
  101. data/config/locales/en.yml +101 -0
  102. data/config/routes.rb +65 -0
  103. data/db/migrate/20260125000001_create_ruby_cms_permissions.rb +14 -0
  104. data/db/migrate/20260125000002_create_ruby_cms_user_permissions.rb +14 -0
  105. data/db/migrate/20260125000003_create_ruby_cms_content_blocks.rb +19 -0
  106. data/db/migrate/20260125000010_add_indexes_to_ruby_cms_tables.rb +9 -0
  107. data/db/migrate/20260127000001_add_locale_to_ruby_cms_content_blocks.rb +34 -0
  108. data/db/migrate/20260129000001_create_ruby_cms_visitor_errors.rb +24 -0
  109. data/db/migrate/20260130000001_add_referer_and_query_to_ruby_cms_visitor_errors.rb +8 -0
  110. data/db/migrate/20260130000002_create_ruby_cms_preferences.rb +16 -0
  111. data/db/migrate/20260130000003_add_category_to_ruby_cms_preferences.rb +8 -0
  112. data/db/migrate/20260211000001_add_ruby_cms_analytics_fields_to_ahoy_events.rb +19 -0
  113. data/db/migrate/20260212000001_use_unprefixed_cms_tables.rb +146 -0
  114. data/exe/ruby_cms +25 -0
  115. data/lib/generators/ruby_cms/install_generator.rb +1062 -0
  116. data/lib/generators/ruby_cms/templates/admin.html.erb +82 -0
  117. data/lib/generators/ruby_cms/templates/ruby_cms.rb +86 -0
  118. data/lib/ruby_cms/app_integration.rb +82 -0
  119. data/lib/ruby_cms/cli.rb +169 -0
  120. data/lib/ruby_cms/content_blocks_grouping.rb +41 -0
  121. data/lib/ruby_cms/content_blocks_sync.rb +329 -0
  122. data/lib/ruby_cms/css_compiler.rb +35 -0
  123. data/lib/ruby_cms/engine.rb +498 -0
  124. data/lib/ruby_cms/settings.rb +145 -0
  125. data/lib/ruby_cms/settings_registry.rb +289 -0
  126. data/lib/ruby_cms/version.rb +5 -0
  127. data/lib/ruby_cms.rb +195 -0
  128. data/lib/tasks/ruby_cms.rake +27 -0
  129. data/log/test.log +17875 -0
  130. data/sig/ruby_cms.rbs +4 -0
  131. metadata +223 -0
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module AdminTurboTable
5
+ extend ActiveSupport::Concern
6
+
7
+ # Check if this is a Turbo Frame request
8
+ # @return [Boolean]
9
+ def turbo_frame_request?
10
+ request.headers["Turbo-Frame"].present?
11
+ end
12
+
13
+ # Render index action with Turbo Frame support
14
+ # If Turbo Frame request, renders only the table content
15
+ # Otherwise renders full page
16
+ # @param turbo_frame_id [String] Turbo Frame ID (default: "admin_table_content")
17
+ def turbo_render_index(turbo_frame_id: "admin_table_content")
18
+ if turbo_frame_request?
19
+ render partial: turbo_frame_id, layout: false
20
+ else
21
+ render :index
22
+ end
23
+ end
24
+
25
+ # Redirect with Turbo Frame support
26
+ # If Turbo Frame request, renders Turbo Stream redirect
27
+ # Otherwise performs normal redirect
28
+ # @param url [String] URL to redirect to
29
+ # @param options [Hash] Redirect options (notice, alert, etc.)
30
+ def turbo_redirect_to(url, **)
31
+ if turbo_frame_request?
32
+ # For Turbo Frame requests, we can't redirect directly
33
+ # Instead, we should render a Turbo Stream that updates the frame
34
+ # or redirect the parent window
35
+ else
36
+ redirect_to(url, **)
37
+ end
38
+ end
39
+
40
+ # Get Turbo Frame ID from request
41
+ # @return [String, nil]
42
+ def turbo_frame_id
43
+ request.headers["Turbo-Frame"]
44
+ end
45
+
46
+ # Check if request expects Turbo Stream response
47
+ # @return [Boolean]
48
+ def turbo_stream_request?
49
+ request.headers["Accept"]&.include?("text/vnd.turbo-stream.html")
50
+ end
51
+
52
+ # Render Turbo Stream update for table
53
+ # @param turbo_frame_id [String] Turbo Frame ID
54
+ # @param partial [String] Partial to render (default: turbo_frame_id)
55
+ def turbo_stream_update_table(turbo_frame_id: "admin_table_content", partial: nil)
56
+ partial ||= turbo_frame_id
57
+ render turbo_stream: turbo_stream.update(turbo_frame_id, partial:)
58
+ end
59
+
60
+ # Render Turbo Stream replace for table
61
+ # @param turbo_frame_id [String] Turbo Frame ID
62
+ # @param partial [String] Partial to render (default: turbo_frame_id)
63
+ def turbo_stream_replace_table(turbo_frame_id: "admin_table_content", partial: nil)
64
+ partial ||= turbo_frame_id
65
+ render turbo_stream: turbo_stream.replace(turbo_frame_id, partial:)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # Include in controllers to track page views via Ahoy.
5
+ # Requires the host app to have Ahoy installed (via RubyCMS install generator).
6
+ #
7
+ # Usage:
8
+ # class PagesController < ApplicationController
9
+ # include RubyCms::PageTracking
10
+ # end
11
+ #
12
+ # Sets @page_name to controller_name by default. Override in actions:
13
+ # @page_name = "custom_page_name"
14
+ #
15
+ module PageTracking
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ before_action :set_page_name
20
+ after_action :track_page_view
21
+ end
22
+
23
+ private
24
+
25
+ def set_page_name
26
+ @page_name = controller_name if @page_name.blank?
27
+ end
28
+
29
+ def track_page_view
30
+ return unless should_track_page_view?
31
+
32
+ ahoy.track "page_view",
33
+ page_name: @page_name,
34
+ request_path: request.path
35
+ rescue StandardError => e
36
+ Rails.logger.error "[RubyCMS] Failed to track page view: #{e.message}"
37
+ end
38
+
39
+ def should_track_page_view?
40
+ # Only track if @page_name is set
41
+ return false if @page_name.blank?
42
+
43
+ # Skip admin paths
44
+ return false if request.path.start_with?("/admin")
45
+
46
+ # Skip Turbo frame requests (optional - adjust based on needs)
47
+ return false if request.headers["Turbo-Frame"].present?
48
+
49
+ true
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ # Include in ApplicationController to capture public-site errors to VisitorError.
5
+ # Skips admin controllers (paths under /admin) and development environment by default.
6
+ #
7
+ # Usage in ApplicationController:
8
+ # include RubyCms::VisitorErrorCapture
9
+ # rescue_from StandardError, with: :handle_visitor_error
10
+ #
11
+ # Or use the class method to add both:
12
+ # RubyCms::VisitorErrorCapture.install(self)
13
+ module VisitorErrorCapture
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ def install(controller_class)
18
+ controller_class.include RubyCms::VisitorErrorCapture
19
+ controller_class.rescue_from StandardError, with: :handle_visitor_error
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def handle_visitor_error(exception)
26
+ return if skip_visitor_error_capture?
27
+
28
+ RubyCms::VisitorError.log_error(exception, request)
29
+ ensure
30
+ raise exception
31
+ end
32
+
33
+ def skip_visitor_error_capture?
34
+ return true if Rails.env.development?
35
+
36
+ false
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module RubyCms
6
+ module Admin
7
+ class AnalyticsController < BaseController
8
+ before_action { require_permission!(:manage_analytics) }
9
+ before_action :set_date_range
10
+ before_action :validate_date_range
11
+
12
+ def index
13
+ report = RubyCms::Analytics::Report.new(
14
+ start_date: @start_date,
15
+ end_date: @end_date,
16
+ period: @period
17
+ )
18
+ @stats = report.dashboard_stats
19
+ @stats.each {|key, value| instance_variable_set(:"@#{key}", value) }
20
+ end
21
+
22
+ def page_details
23
+ @page_name = sanitize_page_name(params[:page_name])
24
+ unless @page_name
25
+ return redirect_to ruby_cms_admin_analytics_path,
26
+ alert: t("ruby_cms.admin.analytics.invalid_page_name",
27
+ default: "Invalid page name.")
28
+ end
29
+
30
+ report = RubyCms::Analytics::Report.new(
31
+ start_date: @start_date,
32
+ end_date: @end_date,
33
+ period: @period
34
+ )
35
+ data = report.page_stats(@page_name)
36
+ @page_views = data[:page_views]
37
+ @page_stats = data[:stats]
38
+ end
39
+
40
+ def visitor_details
41
+ @ip_address = sanitize_ip_address(params[:ip_address])
42
+ unless @ip_address
43
+ return redirect_to ruby_cms_admin_analytics_path,
44
+ alert: t("ruby_cms.admin.analytics.invalid_ip_address",
45
+ default: "Invalid IP address.")
46
+ end
47
+
48
+ report = RubyCms::Analytics::Report.new(
49
+ start_date: @start_date,
50
+ end_date: @end_date,
51
+ period: @period
52
+ )
53
+ data = report.visitor_stats(@ip_address)
54
+ @visitor_views = data[:visitor_views]
55
+ @visitor_stats = data[:stats]
56
+ end
57
+
58
+ private
59
+
60
+ def set_date_range
61
+ @period = sanitize_period(params[:period]) || default_period
62
+
63
+ @start_date, @end_date = parsed_date_range || default_date_range
64
+ rescue Date::Error
65
+ @start_date, @end_date = fallback_date_range
66
+ end
67
+
68
+ def validate_date_range
69
+ max_days = RubyCms::Settings.get(:analytics_max_date_range_days, default: 365).to_i
70
+ return if valid_date_range?(max_days)
71
+
72
+ redirect_to ruby_cms_admin_analytics_path,
73
+ alert: "Invalid date range. Maximum range is #{max_days} days."
74
+ end
75
+
76
+ def sanitize_period(value)
77
+ %w[day week month year].include?(value.to_s) ? value.to_s : nil
78
+ end
79
+
80
+ def default_period
81
+ RubyCms::Settings.get(:analytics_default_period, default: "week").to_s
82
+ rescue StandardError
83
+ "week"
84
+ end
85
+
86
+ def period_start_date(period, end_date)
87
+ case period
88
+ when "day" then end_date
89
+ when "week" then end_date - 6.days
90
+ when "month" then end_date - 29.days
91
+ else end_date - 364.days
92
+ end
93
+ end
94
+
95
+ def sanitize_page_name(page_name)
96
+ page_name.to_s.gsub(%r{[^a-zA-Z0-9_\-/]}, "").presence
97
+ end
98
+
99
+ def sanitize_ip_address(ip_address)
100
+ return nil if ip_address.blank?
101
+
102
+ IPAddr.new(ip_address)
103
+ ip_address
104
+ rescue IPAddr::InvalidAddressError
105
+ nil
106
+ end
107
+
108
+ helper_method :format_chart_date, :format_chart_date_short
109
+
110
+ def format_chart_date(date_string)
111
+ format_chart_date_by_granularity(date_string, long: true)
112
+ rescue Date::Error
113
+ date_string.to_s
114
+ end
115
+
116
+ def format_chart_date_short(date_string)
117
+ format_chart_date_by_granularity(date_string, long: false)
118
+ rescue Date::Error
119
+ date_string.to_s
120
+ end
121
+
122
+ def format_daily_date(date_string)
123
+ date = Date.parse(date_string.to_s)
124
+ if @period == "month"
125
+ end_date = [date + 2.days, @end_date].min
126
+ return "#{date.strftime('%b %d')} - #{end_date.strftime('%b %d')}" if date != end_date
127
+ end
128
+ date.strftime("%B %d, %Y")
129
+ end
130
+
131
+ def format_daily_date_short(date_string)
132
+ date = Date.parse(date_string.to_s)
133
+ if @period == "month"
134
+ end_date = [date + 2.days, @end_date].min
135
+ return "#{date.strftime('%m/%d')}-#{end_date.strftime('%m/%d')}" if date != end_date
136
+ end
137
+ date.strftime("%m/%d")
138
+ end
139
+
140
+ def parsed_date_range
141
+ return nil unless params[:start_date].present? && params[:end_date].present?
142
+
143
+ [
144
+ Date.parse(params[:start_date]),
145
+ Date.parse(params[:end_date])
146
+ ]
147
+ end
148
+
149
+ def default_date_range
150
+ end_date = Date.current
151
+ [period_start_date(@period, end_date), end_date]
152
+ end
153
+
154
+ def fallback_date_range
155
+ end_date = Date.current
156
+ [end_date - 6.days, end_date]
157
+ end
158
+
159
+ def valid_date_range?(max_days)
160
+ return false unless @end_date.between?(@start_date, Date.current)
161
+
162
+ (@end_date - @start_date).to_i <= max_days
163
+ end
164
+
165
+ def format_chart_date_by_granularity(date_string, long:)
166
+ str = date_string.to_s
167
+
168
+ if str.match?(/\A\d{4}-\d{2}-\d{2}\z/)
169
+ return long ? format_daily_date(str) : format_daily_date_short(str)
170
+ end
171
+
172
+ if str.match?(/\A\d{4}-\d{2}\z/)
173
+ format_monthly_date(str, long:)
174
+ elsif str.match?(/\A\d{2}\z/)
175
+ format_hourly_date(str, long:)
176
+ else
177
+ str
178
+ end
179
+ end
180
+
181
+ def format_monthly_date(date_string, long:)
182
+ date = Date.parse("#{date_string}-01")
183
+ long ? date.strftime("%B %Y") : date.strftime("%b")
184
+ end
185
+
186
+ def format_hourly_date(date_string, long:)
187
+ long ? "#{date_string}:00" : "#{date_string}h"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCms
4
+ module Admin
5
+ # Base for all /admin controllers. Ensures authentication and permission enforcement.
6
+ # Inherits from the host's ApplicationController (or config.admin_base_controller).
7
+ # This layout must not be used for public pages.
8
+ class BaseController < Rails.application.config.ruby_cms.admin_base_controller.constantize
9
+ layout -> { Rails.application.config.ruby_cms.admin_layout.presence || "admin/admin" }
10
+ before_action :set_cms_locale
11
+ before_action :require_cms_access
12
+
13
+ rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
14
+
15
+ helper_method :current_user_cms, :require_permission!
16
+
17
+ # Expose the engine's route helpers (ruby_cms_admin_*_path) in controllers.
18
+ # The host's _routes does not expose the mounted engine's named routes to
19
+ # the engine's own controllers; we keep _routes as the host's for
20
+ # new_session_path, root_path, etc.
21
+ include RubyCms::Engine.routes.url_helpers
22
+
23
+ private
24
+
25
+ def require_cms_access
26
+ ensure_authenticated
27
+ require_permission!(:manage_admin)
28
+ end
29
+
30
+ def ensure_authenticated
31
+ return if current_user_cms
32
+
33
+ if respond_to?(:require_authentication, true)
34
+ send(:require_authentication)
35
+ else
36
+ redirect_to cms_redirect_path, alert: t("ruby_cms.admin.base.authentication_required")
37
+ end
38
+ end
39
+
40
+ # Forbid (403) or redirect with flash. Default-deny: unknown permission = forbidden.
41
+ def require_permission!(permission_key, record: nil)
42
+ return if current_user_cms&.can?(permission_key, record:)
43
+
44
+ respond_to do |format|
45
+ format.html do
46
+ redirect_to(cms_redirect_path, alert: t("ruby_cms.admin.base.not_authorized"))
47
+ end
48
+ format.any { head :forbidden }
49
+ end
50
+ end
51
+
52
+ # Optional role gate. Example: before_action { require_role!(:admin) } in a subclass.
53
+ # Uses user.admin? when role is :admin (if the host's User responds to :admin?).
54
+ def require_role!(role)
55
+ return if current_user_cms.nil?
56
+ return if role == :admin && current_user_cms.respond_to?(:admin?) && current_user_cms.admin?
57
+
58
+ respond_to do |format|
59
+ format.html do
60
+ redirect_to cms_redirect_path, alert: t("ruby_cms.admin.base.not_authorized")
61
+ end
62
+ format.any { head :forbidden }
63
+ end
64
+ end
65
+
66
+ def cms_redirect_path
67
+ Rails.application.config.ruby_cms.unauthorized_redirect_path.presence || "/"
68
+ end
69
+
70
+ def current_user_cms
71
+ @current_user_cms ||= resolve_current_user
72
+ end
73
+
74
+ def resolve_current_user
75
+ if respond_to?(:current_user, true)
76
+ send(:current_user)
77
+ else
78
+ Rails.application.config.ruby_cms.current_user_resolver&.call(self)
79
+ end
80
+ end
81
+
82
+ def render_not_found
83
+ render "ruby_cms/errors/not_found",
84
+ status: :not_found,
85
+ layout: Rails.application.config.ruby_cms.admin_layout.presence || "admin/admin"
86
+ end
87
+
88
+ def set_cms_locale
89
+ if session[:ruby_cms_locale].present? &&
90
+ I18n.available_locales.include?(session[:ruby_cms_locale].to_sym)
91
+ I18n.locale = session[:ruby_cms_locale].to_sym
92
+ end
93
+ end
94
+
95
+ # Resolve parameter key for model params
96
+ # Checks if a specific key exists in params, otherwise falls back to model's param_key
97
+ # @param model_class [Class] The model class (e.g., ContentBlock)
98
+ # @param param_name [Symbol] The expected parameter name (e.g., :page)
99
+ # @return [Symbol] The resolved parameter key
100
+ def model_param_key(model_class, param_name)
101
+ params.key?(param_name) ? param_name : model_class.model_name.param_key.to_sym
102
+ end
103
+ end
104
+ end
105
+ end