easy-admin-rails 0.1.15 → 0.2.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +254 -18
  3. data/app/assets/builds/easy_admin.base.js.map +4 -4
  4. data/app/assets/builds/easy_admin.css +112 -18
  5. data/app/components/easy_admin/base_component.rb +1 -0
  6. data/app/components/easy_admin/form_tabs_component.rb +5 -2
  7. data/app/components/easy_admin/navbar_component.rb +5 -1
  8. data/app/components/easy_admin/permissions/user_role_assignment_component.rb +254 -0
  9. data/app/components/easy_admin/permissions/user_role_permissions_component.rb +186 -0
  10. data/app/components/easy_admin/resources/index_component.rb +1 -4
  11. data/app/components/easy_admin/sidebar_component.rb +67 -2
  12. data/app/controllers/easy_admin/application_controller.rb +131 -1
  13. data/app/controllers/easy_admin/batch_actions_controller.rb +27 -0
  14. data/app/controllers/easy_admin/concerns/belongs_to_editing.rb +201 -0
  15. data/app/controllers/easy_admin/concerns/inline_field_editing.rb +297 -0
  16. data/app/controllers/easy_admin/concerns/resource_authorization.rb +55 -0
  17. data/app/controllers/easy_admin/concerns/resource_filtering.rb +178 -0
  18. data/app/controllers/easy_admin/concerns/resource_loading.rb +149 -0
  19. data/app/controllers/easy_admin/concerns/resource_pagination.rb +135 -0
  20. data/app/controllers/easy_admin/dashboard_controller.rb +2 -1
  21. data/app/controllers/easy_admin/dashboards_controller.rb +6 -40
  22. data/app/controllers/easy_admin/resources_controller.rb +13 -762
  23. data/app/controllers/easy_admin/row_actions_controller.rb +25 -0
  24. data/app/helpers/easy_admin/fields_helper.rb +61 -9
  25. data/app/javascript/easy_admin/controllers/event_emitter_controller.js +2 -4
  26. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +0 -10
  27. data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +1 -4
  28. data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +227 -0
  29. data/app/javascript/easy_admin/controllers/role_preview_controller.js +93 -0
  30. data/app/javascript/easy_admin/controllers/select_field_controller.js +1 -2
  31. data/app/javascript/easy_admin/controllers/settings_button_controller.js +1 -2
  32. data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +1 -4
  33. data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +0 -2
  34. data/app/javascript/easy_admin/controllers.js +5 -1
  35. data/app/models/easy_admin/admin_user.rb +6 -0
  36. data/app/policies/admin_user_policy.rb +36 -0
  37. data/app/policies/application_policy.rb +83 -0
  38. data/app/views/easy_admin/application/authorization_failure.turbo_stream.erb +8 -0
  39. data/app/views/easy_admin/dashboards/card.html.erb +5 -0
  40. data/app/views/easy_admin/dashboards/card.turbo_stream.erb +7 -0
  41. data/app/views/easy_admin/dashboards/card_error.html.erb +3 -0
  42. data/app/views/easy_admin/dashboards/card_error.turbo_stream.erb +5 -0
  43. data/app/views/easy_admin/dashboards/show.turbo_stream.erb +7 -0
  44. data/app/views/easy_admin/resources/belongs_to_edit_attached.html.erb +6 -0
  45. data/app/views/easy_admin/resources/belongs_to_edit_attached.turbo_stream.erb +8 -0
  46. data/app/views/easy_admin/resources/belongs_to_reattach.html.erb +5 -0
  47. data/app/views/easy_admin/resources/edit.html.erb +1 -1
  48. data/app/views/easy_admin/resources/edit_field.html.erb +5 -0
  49. data/app/views/easy_admin/resources/edit_field.turbo_stream.erb +7 -0
  50. data/app/views/easy_admin/resources/index.html.erb +1 -1
  51. data/app/views/easy_admin/resources/index_frame.html.erb +8 -142
  52. data/app/views/easy_admin/resources/update_belongs_to_attached.turbo_stream.erb +25 -0
  53. data/app/views/layouts/easy_admin/application.html.erb +15 -2
  54. data/config/initializers/easy_admin_permissions.rb +73 -0
  55. data/db/seeds/easy_admin_permissions.rb +121 -0
  56. data/lib/easy-admin-rails.rb +2 -0
  57. data/lib/easy_admin/permissions/component.rb +168 -0
  58. data/lib/easy_admin/permissions/configuration.rb +37 -0
  59. data/lib/easy_admin/permissions/controller.rb +164 -0
  60. data/lib/easy_admin/permissions/dsl.rb +180 -0
  61. data/lib/easy_admin/permissions/models.rb +44 -0
  62. data/lib/easy_admin/permissions/permission_denied_component.rb +121 -0
  63. data/lib/easy_admin/permissions/resource_permissions.rb +231 -0
  64. data/lib/easy_admin/permissions/role_definition.rb +45 -0
  65. data/lib/easy_admin/permissions/role_denied_component.rb +159 -0
  66. data/lib/easy_admin/permissions/role_dsl.rb +73 -0
  67. data/lib/easy_admin/permissions/user_extensions.rb +129 -0
  68. data/lib/easy_admin/permissions.rb +113 -0
  69. data/lib/easy_admin/resource/base.rb +119 -0
  70. data/lib/easy_admin/resource/configuration.rb +148 -0
  71. data/lib/easy_admin/resource/dsl.rb +117 -0
  72. data/lib/easy_admin/resource/field_registry.rb +189 -0
  73. data/lib/easy_admin/resource/form_builder.rb +123 -0
  74. data/lib/easy_admin/resource/layout_builder.rb +249 -0
  75. data/lib/easy_admin/resource/scope_manager.rb +252 -0
  76. data/lib/easy_admin/resource/show_builder.rb +359 -0
  77. data/lib/easy_admin/resource.rb +8 -835
  78. data/lib/easy_admin/resource_modules.rb +11 -0
  79. data/lib/easy_admin/version.rb +1 -1
  80. data/lib/generators/easy_admin/permissions/install_generator.rb +90 -0
  81. data/lib/generators/easy_admin/permissions/templates/initializers/permissions.rb +37 -0
  82. data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +27 -0
  83. data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +6 -0
  84. data/lib/generators/easy_admin/permissions/templates/models/permission.rb +9 -0
  85. data/lib/generators/easy_admin/permissions/templates/models/role.rb +9 -0
  86. data/lib/generators/easy_admin/permissions/templates/models/role_permission.rb +9 -0
  87. data/lib/generators/easy_admin/permissions/templates/models/user_role.rb +9 -0
  88. data/lib/generators/easy_admin/permissions/templates/policies/application_policy.rb +47 -0
  89. data/lib/generators/easy_admin/permissions/templates/policies/user_policy.rb +36 -0
  90. data/lib/generators/easy_admin/permissions/templates/seeds/permissions.rb +89 -0
  91. metadata +62 -5
  92. data/db/migrate/20250101000001_create_easy_admin_admin_users.rb +0 -45
@@ -0,0 +1,186 @@
1
+ module EasyAdmin
2
+ module Permissions
3
+ class UserRolePermissionsComponent < EasyAdmin::BaseComponent
4
+ def initialize(user:)
5
+ @user = user
6
+ # Use direct role association for role info
7
+ if user && user.respond_to?(:role)
8
+ user.reload if user.persisted?
9
+ @current_role = user.role
10
+ else
11
+ @current_role = nil
12
+ end
13
+
14
+ # Get actual permissions from permissions_cache
15
+ @user_permissions = get_user_permissions_from_cache(user)
16
+ @all_permissions = EasyAdmin::Permissions::Permission.all.order(:resource_type, :action)
17
+
18
+ Rails.logger.debug "UserRolePermissionsComponent: user=#{@user&.id}, role=#{@current_role&.name}, cached_permissions=#{@user_permissions.size}"
19
+ end
20
+
21
+ def view_template
22
+ div(class: "space-y-6") do
23
+ # Current Role Section
24
+ render_current_role_section
25
+
26
+ # Individual Permissions Section (from cache)
27
+ render_permissions_section
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def render_current_role_section
34
+ div(class: "bg-white border border-gray-200 rounded-lg p-6") do
35
+ div(class: "flex items-center justify-between mb-4") do
36
+ h3(class: "text-lg font-medium text-gray-900") { "Current Role" }
37
+
38
+ if @current_role
39
+ span(class: "inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800") do
40
+ @current_role.name
41
+ end
42
+ else
43
+ span(class: "inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800") do
44
+ "No role assigned"
45
+ end
46
+ end
47
+ end
48
+
49
+ if @current_role
50
+ if @current_role.description.present?
51
+ p(class: "text-gray-600 mb-4") { @current_role.description }
52
+ end
53
+
54
+ div(class: "grid grid-cols-3 gap-4 text-sm") do
55
+ div do
56
+ span(class: "font-medium text-gray-500") { "Role Slug:" }
57
+ p(class: "text-gray-900 font-mono") { @current_role.slug }
58
+ end
59
+
60
+ div do
61
+ span(class: "font-medium text-gray-500") { "Permissions Count:" }
62
+ p(class: "text-gray-900") { @current_role.permissions.count.to_s }
63
+ end
64
+
65
+ div do
66
+ span(class: "font-medium text-gray-500") { "Status:" }
67
+ p(class: "text-green-600 font-medium") { @current_role.active? ? "Active" : "Inactive" }
68
+ end
69
+ end
70
+ else
71
+ p(class: "text-gray-500 italic") do
72
+ "This user has not been assigned any role yet."
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ def render_permissions_section
79
+ div(class: "bg-white border border-gray-200 rounded-lg p-6") do
80
+ div(class: "mb-4") do
81
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "Individual Permissions" }
82
+ p(class: "text-sm text-gray-600") do
83
+ "The following permissions have been specifically assigned to this user:"
84
+ end
85
+ end
86
+
87
+ # Get permissions that are granted (true) from cache
88
+ granted_permission_names = @user_permissions.select { |name, granted| granted == "true" }.keys
89
+ granted_permissions = @all_permissions.select { |p| granted_permission_names.include?(p.name) }
90
+
91
+ if granted_permissions.any?
92
+ # Group permissions by resource type
93
+ grouped_permissions = granted_permissions.group_by(&:resource_type)
94
+
95
+ div(class: "space-y-6") do
96
+ grouped_permissions.each do |resource_type, resource_permissions|
97
+ render_resource_permissions_group(resource_type, resource_permissions, granted: true)
98
+ end
99
+ end
100
+ else
101
+ render_no_permissions
102
+ end
103
+ end
104
+ end
105
+
106
+ def render_resource_permissions_group(resource_type, permissions, granted: true)
107
+ div(class: "border border-gray-200 rounded-lg p-4") do
108
+ # Resource header
109
+ div(class: "flex items-center mb-3") do
110
+ h4(class: "text-md font-medium text-gray-900 capitalize") { resource_type.humanize }
111
+ span(class: "ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700") do
112
+ "#{permissions.count} permissions"
113
+ end
114
+ end
115
+
116
+ # Permissions grid
117
+ div(class: "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3") do
118
+ permissions.each do |permission|
119
+ render_permission_card(permission)
120
+ end
121
+ end
122
+ end
123
+ end
124
+
125
+ def render_permission_card(permission)
126
+ div(class: "flex items-start p-3 bg-green-50 border border-green-200 rounded-lg") do
127
+ # Permission icon
128
+ div(class: "flex-shrink-0 mr-3") do
129
+ unsafe_raw '<svg class="w-5 h-5 text-green-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>'
130
+ end
131
+
132
+ # Permission details
133
+ div(class: "flex-1 min-w-0") do
134
+ div(class: "flex items-center mb-1") do
135
+ span(class: "text-sm font-medium text-gray-900 capitalize") { permission.action.humanize }
136
+ span(class: "ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800") do
137
+ permission.action
138
+ end
139
+ end
140
+
141
+ if permission.description.present?
142
+ p(class: "text-xs text-gray-600 leading-relaxed") { permission.description }
143
+ end
144
+
145
+ # Permission name (technical)
146
+ p(class: "text-xs text-gray-500 font-mono mt-1") { permission.name }
147
+ end
148
+ end
149
+ end
150
+
151
+ def render_no_permissions
152
+ div(class: "text-center py-8") do
153
+ unsafe_raw '<svg class="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>'
154
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "No Permissions" }
155
+ p(class: "text-gray-600") { "This role has no permissions assigned." }
156
+ end
157
+ end
158
+
159
+ def render_no_role_assigned
160
+ div(class: "text-center py-12 bg-gray-50 border border-gray-200 rounded-lg") do
161
+ unsafe_raw '<svg class="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>'
162
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "No Role Assigned" }
163
+ p(class: "text-gray-600 mb-4") { "This user needs to be assigned a role to view their permissions." }
164
+
165
+ div(class: "text-sm text-gray-500") do
166
+ p { "Available roles: super_admin, admin, editor, moderator, viewer" }
167
+ end
168
+ end
169
+ end
170
+
171
+ # Helper method to extract permissions from permissions_cache JSON field
172
+ def get_user_permissions_from_cache(user)
173
+ return {} unless user&.permissions_cache.present?
174
+
175
+ case user.permissions_cache
176
+ when Hash
177
+ user.permissions_cache
178
+ when String
179
+ JSON.parse(user.permissions_cache) rescue {}
180
+ else
181
+ {}
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -28,10 +28,7 @@ module EasyAdmin
28
28
  div(class: "mb-6") do
29
29
  div(class: "sm:flex sm:items-center") do
30
30
  div(class: "sm:flex-auto") do
31
- h1(class: "text-2xl font-bold leading-6 text-gray-900") { @resource_class.title }
32
- p(class: "mt-2 text-sm text-gray-700") do
33
- "A list of all #{@resource_class.title.downcase} in your account."
34
- end
31
+ # Title now handled by navbar via content_for in ERB template
35
32
  end
36
33
  div(class: "mt-4 sm:ml-16 sm:mt-0 sm:flex-none") do
37
34
  render_new_resource_link
@@ -1,8 +1,9 @@
1
1
  module EasyAdmin
2
2
  class SidebarComponent < Phlex::HTML
3
- def initialize(items: nil, current_path: nil)
3
+ def initialize(items: nil, current_path: nil, current_user: nil)
4
4
  @items = items || EasyAdmin.configuration.sidebar_items
5
5
  @current_path = current_path
6
+ @current_user = current_user
6
7
  end
7
8
 
8
9
  def view_template
@@ -51,7 +52,7 @@ module EasyAdmin
51
52
  # Navigation menu
52
53
  nav(class: "flex-1 px-4 py-6 overflow-y-auto") do
53
54
  ul(class: "space-y-2") do
54
- @items.each do |item|
55
+ filtered_items.each do |item|
55
56
  render_sidebar_item(item)
56
57
  end
57
58
  end
@@ -170,5 +171,69 @@ module EasyAdmin
170
171
  end
171
172
  end
172
173
  end
174
+
175
+ # Filter items based on permissions
176
+ def filtered_items
177
+ return @items unless @current_user
178
+
179
+ @items.filter_map do |item|
180
+ if item[:children]&.any?
181
+ # This is a group/parent item
182
+ filtered_children = filter_children(item[:children])
183
+
184
+ # Hide group if no accessible children
185
+ next if filtered_children.empty?
186
+
187
+ # Return group with filtered children
188
+ item.merge(children: filtered_children)
189
+ else
190
+ # This is a simple item - check if user has access
191
+ can_access_item?(item) ? item : nil
192
+ end
193
+ end
194
+ end
195
+
196
+ def filter_children(children)
197
+ return [] unless children
198
+
199
+ children.filter_map do |child|
200
+ if child[:children]&.any?
201
+ # Nested group
202
+ filtered_nested = filter_children(child[:children])
203
+ next if filtered_nested.empty?
204
+ child.merge(children: filtered_nested)
205
+ else
206
+ # Simple child item
207
+ can_access_item?(child) ? child : nil
208
+ end
209
+ end
210
+ end
211
+
212
+ def can_access_item?(item)
213
+ # Allow non-resource items (like Dashboard, Settings, etc.)
214
+ return true unless item[:resource]
215
+
216
+ # For resource items, check permission
217
+ resource_name = item[:resource]
218
+ # Convert complex resource names to permission format
219
+ permission_resource_name = convert_resource_to_permission_name(resource_name)
220
+ permission_name = "#{permission_resource_name}:read"
221
+
222
+ EasyAdmin::Permissions.authorized?(@current_user, permission_name)
223
+ end
224
+
225
+ def convert_resource_to_permission_name(resource_name)
226
+ # Handle complex resource names like "Catalog::PaymentMethod" -> "payment_methods"
227
+ if resource_name.include?('::')
228
+ # Get the last part and convert to snake_case plural
229
+ resource_name.split('::').last.underscore.pluralize
230
+ elsif resource_name.start_with?('EasyAdmin::')
231
+ # Handle EasyAdmin resources like "EasyAdmin::AdminUser" -> "admin_users"
232
+ resource_name.sub('EasyAdmin::', '').underscore.pluralize
233
+ else
234
+ # Simple resource names, just pluralize
235
+ resource_name.underscore.pluralize
236
+ end
237
+ end
173
238
  end
174
239
  end
@@ -1,6 +1,7 @@
1
1
  module EasyAdmin
2
2
  class ApplicationController < ActionController::Base
3
3
  include Pagy::Backend
4
+ include ActionPolicy::Controller
4
5
 
5
6
  helper EasyAdmin::FieldsHelper
6
7
  helper EasyAdmin::PagyHelper
@@ -10,6 +11,18 @@ module EasyAdmin
10
11
  before_action :set_feature_toggles
11
12
  before_action :set_paper_trail_whodunnit
12
13
 
14
+ # ActionPolicy authorization context
15
+ authorize :user, through: :current_admin_user
16
+
17
+ # Handle ActionPolicy authorization failures
18
+ rescue_from ActionPolicy::Unauthorized, with: :handle_authorization_failure
19
+
20
+ # Configure ActionPolicy to use ApplicationPolicy as default for all models
21
+ def policy_for(record:, **opts)
22
+ # Always use ApplicationPolicy for all models (EasyAdmin and regular models)
23
+ ApplicationPolicy.new(record, **authorization_context, **opts)
24
+ end
25
+
13
26
  protected
14
27
 
15
28
  def authenticate_easy_admin_admin_user!
@@ -69,6 +82,123 @@ module EasyAdmin
69
82
  end
70
83
  end
71
84
 
72
- helper_method :current_admin_user, :admin_user_signed_in?
85
+ helper_method :current_admin_user, :admin_user_signed_in?, :flash_type_to_notification_type
86
+
87
+ private
88
+
89
+ def handle_authorization_failure(exception)
90
+ Rails.logger.warn "🚫 Authorization failed: #{exception.message}"
91
+ Rails.logger.warn "🚫 User: #{current_admin_user&.email} (Role: #{current_admin_user&.role&.name || 'None'})"
92
+ Rails.logger.warn "🚫 Action: #{action_name} on #{params[:resource_name]}"
93
+
94
+ # Extract meaningful error details
95
+ action_name = extract_action_from_exception(exception)
96
+ resource_name = extract_resource_from_exception(exception)
97
+
98
+ error_message = build_authorization_error_message(action_name, resource_name)
99
+
100
+ # Store data for templates
101
+ @error_message = error_message
102
+ @action_name = action_name
103
+ @resource_name = resource_name
104
+
105
+ respond_to do |format|
106
+ format.html do
107
+ flash[:alert] = error_message
108
+
109
+ # Try to redirect back, fallback to dashboard
110
+ redirect_path = request.referer&.start_with?(request.base_url) ? request.referer : easy_admin.root_path
111
+ redirect_to redirect_path
112
+ end
113
+
114
+ format.turbo_stream do
115
+ render template: 'easy_admin/application/authorization_failure'
116
+ end
117
+
118
+ format.json do
119
+ render json: {
120
+ error: "Access Denied",
121
+ message: error_message,
122
+ user_role: current_admin_user&.role&.name
123
+ }, status: :forbidden
124
+ end
125
+ end
126
+ end
127
+
128
+ def extract_action_from_exception(exception)
129
+ # Try to extract action from exception message
130
+ case exception.message
131
+ when /index\?/
132
+ "view"
133
+ when /show\?/
134
+ "view details of"
135
+ when /create\?/
136
+ "create"
137
+ when /update\?/
138
+ "update"
139
+ when /destroy\?/
140
+ "delete"
141
+ when /batch_action\?/
142
+ "perform batch actions on"
143
+ when /row_action\?/
144
+ "perform actions on"
145
+ when /manage_versions\?/
146
+ "manage versions of"
147
+ else
148
+ "access"
149
+ end
150
+ end
151
+
152
+ def extract_resource_from_exception(exception)
153
+ # Try to extract resource name from exception message or current context
154
+ if defined?(@resource_class) && @resource_class
155
+ # Get the model class name and clean it up for display
156
+ model_name = @resource_class.model_class.name
157
+
158
+ # Handle namespaced models (e.g., "Catalog::PaymentMethod" -> "Payment Methods")
159
+ if model_name.include?('::')
160
+ model_name.split('::').last.underscore.humanize.pluralize.downcase
161
+ else
162
+ model_name.underscore.humanize.pluralize.downcase
163
+ end
164
+ elsif params[:resource_name]
165
+ # Clean up resource name from params (e.g., "catalog/payment_methods" -> "payment methods")
166
+ if params[:resource_name].include?('/')
167
+ params[:resource_name].split('/').last.humanize.downcase
168
+ else
169
+ params[:resource_name].humanize.downcase
170
+ end
171
+ else
172
+ "this resource"
173
+ end
174
+ end
175
+
176
+ def build_authorization_error_message(action_name, resource_name)
177
+ case action_name
178
+ when "view"
179
+ "Access denied: You cannot view #{resource_name}."
180
+ when "create"
181
+ "Access denied: You cannot create new #{resource_name}."
182
+ when "update"
183
+ "Access denied: You cannot edit #{resource_name}."
184
+ when "delete"
185
+ "Access denied: You cannot delete #{resource_name}."
186
+ else
187
+ "Access denied: You cannot #{action_name} #{resource_name}."
188
+ end
189
+ end
190
+
191
+ def flash_type_to_notification_type(flash_type)
192
+ case flash_type.to_s
193
+ when 'alert', 'error'
194
+ :error
195
+ when 'notice', 'success'
196
+ :success
197
+ when 'warning'
198
+ :warning
199
+ else
200
+ :info
201
+ end
202
+ end
73
203
  end
74
204
  end
@@ -2,6 +2,7 @@ module EasyAdmin
2
2
  class BatchActionsController < ApplicationController
3
3
  before_action :set_resource_class
4
4
  before_action :set_selected_records, only: [:execute, :form]
5
+ before_action :authorize_batch_action!, only: [:execute, :form]
5
6
 
6
7
  def execute
7
8
  action_class_name = params[:action_class]
@@ -162,5 +163,31 @@ module EasyAdmin
162
163
  submit_url: submit_url
163
164
  )
164
165
  end
166
+
167
+ def authorize_batch_action!
168
+ action_class_name = params[:action_class]
169
+
170
+ # Basic permission: user must be able to update records for this resource
171
+ @selected_records.each do |record|
172
+ authorize! record, to: :update?
173
+ end
174
+
175
+ # Additional permission: user must have batch action permission for this resource
176
+ authorize! @resource_class.model_class, to: :batch_action?
177
+
178
+ # Specific batch action authorization if the action defines it
179
+ if action_class_name.present?
180
+ begin
181
+ action_class = action_class_name.constantize
182
+ action_permission = "#{@resource_class.resource_name}:#{action_class.name.underscore.split('/').last}"
183
+
184
+ unless current_admin_user && EasyAdmin::Permissions.authorized?(current_admin_user, action_permission)
185
+ raise ActionPolicy::Unauthorized, "Not authorized to perform #{action_class.name} batch action"
186
+ end
187
+ rescue NameError
188
+ # Action class doesn't exist, will be handled in the main action
189
+ end
190
+ end
191
+ end
165
192
  end
166
193
  end
@@ -0,0 +1,201 @@
1
+ module EasyAdmin
2
+ module Concerns
3
+ # BelongsToEditing concern handles belongs_to field specific editing functionality
4
+ # Provides methods for reattaching and editing attached belongs_to records
5
+ module BelongsToEditing
6
+ extend ActiveSupport::Concern
7
+
8
+ # Reattach a belongs_to association (select different associated record)
9
+ def belongs_to_reattach
10
+ @field_name = params[:field]
11
+ @field_config = find_field_config_by_name_or_association(@field_name)
12
+
13
+ unless @field_config
14
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
15
+ return
16
+ end
17
+
18
+ # Convert belongs_to field to select field format for reattaching
19
+ @select_field_config = prepare_belongs_to_as_select(@field_config)
20
+
21
+ respond_to do |format|
22
+ format.html { render template: 'easy_admin/resources/belongs_to_reattach', layout: false }
23
+ end
24
+ end
25
+
26
+ # Edit the attached record in a belongs_to association
27
+ def belongs_to_edit_attached
28
+ @field_name = params[:field]
29
+ @field_config = find_field_config_by_name_or_association(@field_name)
30
+
31
+ unless @field_config
32
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
33
+ return
34
+ end
35
+
36
+ unless @field_config[:type] == :belongs_to
37
+ render json: { error: "Field '#{@field_name}' is not a belongs_to field" }, status: :bad_request
38
+ return
39
+ end
40
+
41
+ # Get the association info - use explicit association name if provided
42
+ association_name = @field_config[:association] || @field_config[:name]
43
+ model_class = @resource_class.model_class
44
+ association_reflection = model_class.reflect_on_association(association_name)
45
+
46
+ unless association_reflection
47
+ render json: { error: "Association '#{association_name}' not found on #{model_class.name}" }, status: :bad_request
48
+ return
49
+ end
50
+
51
+ # Get the associated record using the association name
52
+ @associated_record = @record.public_send(association_name)
53
+
54
+ unless @associated_record
55
+ render json: { error: "No associated record found" }, status: :not_found
56
+ return
57
+ end
58
+
59
+ # Get the resource class for the associated record
60
+ associated_model_class = association_reflection.klass
61
+ @associated_resource_class = find_resource_class_for_model(associated_model_class)
62
+
63
+ unless @associated_resource_class
64
+ render json: { error: "No resource class found for #{associated_model_class.name}" }, status: :not_found
65
+ return
66
+ end
67
+
68
+ respond_to do |format|
69
+ format.html { render template: 'easy_admin/resources/belongs_to_edit_attached', layout: false }
70
+ end
71
+ end
72
+
73
+ # Update the attached record in a belongs_to association
74
+ def update_belongs_to_attached
75
+ @field_name = params[:field]
76
+ @field_config = find_field_config_by_name_or_association(@field_name)
77
+ @attached_id = params[:attached_id]
78
+
79
+ unless @field_config
80
+ render json: { error: "Field '#{@field_name}' not found" }, status: :not_found
81
+ return
82
+ end
83
+
84
+ # Get the association info - use explicit association name if provided
85
+ association_name = @field_config[:association] || @field_config[:name]
86
+ model_class = @resource_class.model_class
87
+ association_reflection = model_class.reflect_on_association(association_name)
88
+
89
+ unless association_reflection
90
+ render json: { error: "Association '#{association_name}' not found on #{model_class.name}" }, status: :bad_request
91
+ return
92
+ end
93
+
94
+ associated_model_class = association_reflection.klass
95
+ associated_resource_class = find_resource_class_for_model(associated_model_class)
96
+
97
+ # Find the attached record
98
+ attached_record = associated_model_class.find(@attached_id)
99
+
100
+ unless attached_record
101
+ render json: { error: "Attached record not found" }, status: :not_found
102
+ return
103
+ end
104
+
105
+ # Get the update parameters for the attached record
106
+ raw_params = params.dig(associated_resource_class.param_key) || {}
107
+
108
+ # Get permitted attributes from the resource class
109
+ permitted_attributes = get_permitted_attributes(associated_resource_class)
110
+ update_params = raw_params.permit(*permitted_attributes)
111
+
112
+ if attached_record.update(update_params)
113
+ @success = true
114
+ @attached_record = attached_record
115
+ @association_name = association_name
116
+ else
117
+ @success = false
118
+ @attached_record = attached_record
119
+ end
120
+
121
+ respond_to do |format|
122
+ format.turbo_stream { render template: 'easy_admin/resources/update_belongs_to_attached' }
123
+ format.json do
124
+ if @success
125
+ render json: { status: 'success' }
126
+ else
127
+ render json: { errors: @attached_record.errors }
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # Helper method to find field config by name or association name
136
+ def find_field_config_by_name_or_association(field_identifier)
137
+ # Try to find field by name first
138
+ field_config = find_field_config(field_identifier)
139
+
140
+ # If not found by field name, try to find by association name
141
+ if !field_config
142
+ field_config = @resource_class.fields_config.find { |f| f[:association].to_s == field_identifier }
143
+ end
144
+
145
+ field_config
146
+ end
147
+
148
+ # Convert belongs_to field to select field format for reattaching
149
+ def prepare_belongs_to_as_select(field_config)
150
+ association_name = field_config[:association] || field_config[:name]
151
+ foreign_key = get_foreign_key_name(association_name)
152
+
153
+ # Get available options for the select
154
+ options = get_belongs_to_options(association_name, field_config)
155
+
156
+ # Return field config formatted as a select field, preserving original info
157
+ field_config.merge(
158
+ type: :select,
159
+ name: foreign_key, # Use foreign key instead of association name
160
+ label: "Select #{field_config[:label]}",
161
+ options: options,
162
+ original_type: :belongs_to,
163
+ original_name: association_name
164
+ )
165
+ end
166
+
167
+ # Get options for belongs_to field selection
168
+ def get_belongs_to_options(association_name, field_config)
169
+ model_class = @resource_class.model_class
170
+
171
+ # Get the associated model class using reflection
172
+ association_reflection = model_class.reflect_on_association(association_name)
173
+ if association_reflection
174
+ association_class = association_reflection.klass
175
+ else
176
+ # Fallback: try to constantize the association name if reflection is not found
177
+ association_class = association_name.to_s.classify.constantize rescue nil
178
+ end
179
+
180
+ return [] unless association_class
181
+
182
+ # Get display method from field config
183
+ display_method = field_config[:display_method] || :name
184
+
185
+ # Get all records and format as [label, value] pairs
186
+ association_class.all.limit(100).map do |record|
187
+ label = if record.respond_to?(display_method)
188
+ record.public_send(display_method)
189
+ elsif record.respond_to?(:name)
190
+ record.name
191
+ elsif record.respond_to?(:title)
192
+ record.title
193
+ else
194
+ record.to_s
195
+ end
196
+ [label, record.id]
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end