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.
- checksums.yaml +4 -4
- data/app/assets/builds/easy_admin.base.js +254 -18
- data/app/assets/builds/easy_admin.base.js.map +4 -4
- data/app/assets/builds/easy_admin.css +112 -18
- data/app/components/easy_admin/base_component.rb +1 -0
- data/app/components/easy_admin/form_tabs_component.rb +5 -2
- data/app/components/easy_admin/navbar_component.rb +5 -1
- data/app/components/easy_admin/permissions/user_role_assignment_component.rb +254 -0
- data/app/components/easy_admin/permissions/user_role_permissions_component.rb +186 -0
- data/app/components/easy_admin/resources/index_component.rb +1 -4
- data/app/components/easy_admin/sidebar_component.rb +67 -2
- data/app/controllers/easy_admin/application_controller.rb +131 -1
- data/app/controllers/easy_admin/batch_actions_controller.rb +27 -0
- data/app/controllers/easy_admin/concerns/belongs_to_editing.rb +201 -0
- data/app/controllers/easy_admin/concerns/inline_field_editing.rb +297 -0
- data/app/controllers/easy_admin/concerns/resource_authorization.rb +55 -0
- data/app/controllers/easy_admin/concerns/resource_filtering.rb +178 -0
- data/app/controllers/easy_admin/concerns/resource_loading.rb +149 -0
- data/app/controllers/easy_admin/concerns/resource_pagination.rb +135 -0
- data/app/controllers/easy_admin/dashboard_controller.rb +2 -1
- data/app/controllers/easy_admin/dashboards_controller.rb +6 -40
- data/app/controllers/easy_admin/resources_controller.rb +13 -762
- data/app/controllers/easy_admin/row_actions_controller.rb +25 -0
- data/app/helpers/easy_admin/fields_helper.rb +61 -9
- data/app/javascript/easy_admin/controllers/event_emitter_controller.js +2 -4
- data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +0 -10
- data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +1 -4
- data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +227 -0
- data/app/javascript/easy_admin/controllers/role_preview_controller.js +93 -0
- data/app/javascript/easy_admin/controllers/select_field_controller.js +1 -2
- data/app/javascript/easy_admin/controllers/settings_button_controller.js +1 -2
- data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +1 -4
- data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +0 -2
- data/app/javascript/easy_admin/controllers.js +5 -1
- data/app/models/easy_admin/admin_user.rb +6 -0
- data/app/policies/admin_user_policy.rb +36 -0
- data/app/policies/application_policy.rb +83 -0
- data/app/views/easy_admin/application/authorization_failure.turbo_stream.erb +8 -0
- data/app/views/easy_admin/dashboards/card.html.erb +5 -0
- data/app/views/easy_admin/dashboards/card.turbo_stream.erb +7 -0
- data/app/views/easy_admin/dashboards/card_error.html.erb +3 -0
- data/app/views/easy_admin/dashboards/card_error.turbo_stream.erb +5 -0
- data/app/views/easy_admin/dashboards/show.turbo_stream.erb +7 -0
- data/app/views/easy_admin/resources/belongs_to_edit_attached.html.erb +6 -0
- data/app/views/easy_admin/resources/belongs_to_edit_attached.turbo_stream.erb +8 -0
- data/app/views/easy_admin/resources/belongs_to_reattach.html.erb +5 -0
- data/app/views/easy_admin/resources/edit.html.erb +1 -1
- data/app/views/easy_admin/resources/edit_field.html.erb +5 -0
- data/app/views/easy_admin/resources/edit_field.turbo_stream.erb +7 -0
- data/app/views/easy_admin/resources/index.html.erb +1 -1
- data/app/views/easy_admin/resources/index_frame.html.erb +8 -142
- data/app/views/easy_admin/resources/update_belongs_to_attached.turbo_stream.erb +25 -0
- data/app/views/layouts/easy_admin/application.html.erb +15 -2
- data/config/initializers/easy_admin_permissions.rb +73 -0
- data/db/seeds/easy_admin_permissions.rb +121 -0
- data/lib/easy-admin-rails.rb +2 -0
- data/lib/easy_admin/permissions/component.rb +168 -0
- data/lib/easy_admin/permissions/configuration.rb +37 -0
- data/lib/easy_admin/permissions/controller.rb +164 -0
- data/lib/easy_admin/permissions/dsl.rb +180 -0
- data/lib/easy_admin/permissions/models.rb +44 -0
- data/lib/easy_admin/permissions/permission_denied_component.rb +121 -0
- data/lib/easy_admin/permissions/resource_permissions.rb +231 -0
- data/lib/easy_admin/permissions/role_definition.rb +45 -0
- data/lib/easy_admin/permissions/role_denied_component.rb +159 -0
- data/lib/easy_admin/permissions/role_dsl.rb +73 -0
- data/lib/easy_admin/permissions/user_extensions.rb +129 -0
- data/lib/easy_admin/permissions.rb +113 -0
- data/lib/easy_admin/resource/base.rb +119 -0
- data/lib/easy_admin/resource/configuration.rb +148 -0
- data/lib/easy_admin/resource/dsl.rb +117 -0
- data/lib/easy_admin/resource/field_registry.rb +189 -0
- data/lib/easy_admin/resource/form_builder.rb +123 -0
- data/lib/easy_admin/resource/layout_builder.rb +249 -0
- data/lib/easy_admin/resource/scope_manager.rb +252 -0
- data/lib/easy_admin/resource/show_builder.rb +359 -0
- data/lib/easy_admin/resource.rb +8 -835
- data/lib/easy_admin/resource_modules.rb +11 -0
- data/lib/easy_admin/version.rb +1 -1
- data/lib/generators/easy_admin/permissions/install_generator.rb +90 -0
- data/lib/generators/easy_admin/permissions/templates/initializers/permissions.rb +37 -0
- data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +27 -0
- data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +6 -0
- data/lib/generators/easy_admin/permissions/templates/models/permission.rb +9 -0
- data/lib/generators/easy_admin/permissions/templates/models/role.rb +9 -0
- data/lib/generators/easy_admin/permissions/templates/models/role_permission.rb +9 -0
- data/lib/generators/easy_admin/permissions/templates/models/user_role.rb +9 -0
- data/lib/generators/easy_admin/permissions/templates/policies/application_policy.rb +47 -0
- data/lib/generators/easy_admin/permissions/templates/policies/user_policy.rb +36 -0
- data/lib/generators/easy_admin/permissions/templates/seeds/permissions.rb +89 -0
- metadata +62 -5
- 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
|
-
|
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
|
-
|
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
|