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
@@ -3,6 +3,8 @@ module EasyAdmin
|
|
3
3
|
before_action :load_resource_class
|
4
4
|
before_action :load_record
|
5
5
|
before_action :load_action_class, only: [:execute, :form]
|
6
|
+
before_action :authorize_row_action_access!, only: [:context_menu]
|
7
|
+
before_action :authorize_row_action_execution!, only: [:execute, :form]
|
6
8
|
|
7
9
|
# Render context menu with available actions
|
8
10
|
def context_menu
|
@@ -212,5 +214,28 @@ module EasyAdmin
|
|
212
214
|
|
213
215
|
streams
|
214
216
|
end
|
217
|
+
|
218
|
+
def authorize_row_action_access!
|
219
|
+
# User must have read access to the record to see row actions
|
220
|
+
authorize! @record, to: :show?
|
221
|
+
# User must have row actions permission for this resource
|
222
|
+
authorize! @resource_class.model_class, to: :row_action?
|
223
|
+
end
|
224
|
+
|
225
|
+
def authorize_row_action_execution!
|
226
|
+
# User must have update access to execute row actions on the record
|
227
|
+
authorize! @record, to: :update?
|
228
|
+
# User must have row actions permission for this resource
|
229
|
+
authorize! @resource_class.model_class, to: :row_action?
|
230
|
+
|
231
|
+
# Additional permission: specific row action authorization
|
232
|
+
if @action_class
|
233
|
+
action_permission = "#{@resource_class.resource_name}:#{@action_class.name.underscore.split('/').last}"
|
234
|
+
|
235
|
+
unless current_admin_user && EasyAdmin::Permissions.authorized?(current_admin_user, action_permission)
|
236
|
+
raise ActionPolicy::Unauthorized, "Not authorized to perform #{@action_class.name} row action"
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
215
240
|
end
|
216
241
|
end
|
@@ -1,16 +1,68 @@
|
|
1
1
|
module EasyAdmin
|
2
2
|
module FieldsHelper
|
3
3
|
def render_field(field, action:, value: nil, record: nil, form: nil)
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
4
|
+
# Handle custom components and content
|
5
|
+
case field[:type]
|
6
|
+
when :custom_component
|
7
|
+
# Recreate component with current context (including record)
|
8
|
+
if field[:component_class] && record
|
9
|
+
component_options = field[:component_options] || {}
|
10
|
+
component_options[:user] = record if component_options[:user].nil?
|
11
|
+
component = field[:component_class].new(**component_options)
|
12
|
+
return component.call.html_safe
|
13
|
+
elsif field[:component_instance] && field[:component_instance].respond_to?(:call)
|
14
|
+
# Fallback to stored instance
|
15
|
+
return field[:component_instance].call.html_safe
|
16
|
+
end
|
17
|
+
return ""
|
18
|
+
when :custom_content
|
19
|
+
if field[:block]
|
20
|
+
Rails.logger.debug "🔍 [FieldsHelper] Processing custom_content block"
|
21
|
+
Rails.logger.debug "🔍 [FieldsHelper] Form present: #{form.present?}"
|
22
|
+
Rails.logger.debug "🔍 [FieldsHelper] Record present: #{record.present?}"
|
23
|
+
|
24
|
+
# Create a context object with access to form, record, and helpers
|
25
|
+
context = OpenStruct.new(form: form, record: record, helpers: self)
|
26
|
+
|
27
|
+
# Call the block directly to get the result (don't use capture which converts to string)
|
28
|
+
result = field[:block].call(context)
|
29
|
+
|
30
|
+
Rails.logger.debug "🔍 [FieldsHelper] Block result class: #{result.class}"
|
31
|
+
Rails.logger.debug "🔍 [FieldsHelper] Result responds to call: #{result.respond_to?(:call)}"
|
32
|
+
Rails.logger.debug "🔍 [FieldsHelper] Result responds to view_template: #{result.respond_to?(:view_template)}"
|
33
|
+
|
34
|
+
# Handle different types of results
|
35
|
+
if result.respond_to?(:call) && result.respond_to?(:view_template)
|
36
|
+
Rails.logger.debug "🔍 [FieldsHelper] Rendering Phlex component"
|
37
|
+
# It's a Phlex component - use Rails render helper
|
38
|
+
render(result)
|
39
|
+
elsif result.is_a?(String)
|
40
|
+
Rails.logger.debug "🔍 [FieldsHelper] Rendering string result"
|
41
|
+
result.html_safe
|
42
|
+
elsif result.respond_to?(:to_s)
|
43
|
+
Rails.logger.debug "🔍 [FieldsHelper] Converting to string and rendering"
|
44
|
+
result.to_s.html_safe
|
45
|
+
else
|
46
|
+
Rails.logger.debug "🔍 [FieldsHelper] No valid result, returning empty string"
|
47
|
+
""
|
48
|
+
end
|
49
|
+
else
|
50
|
+
Rails.logger.debug "🔍 [FieldsHelper] No block provided for custom_content"
|
51
|
+
""
|
52
|
+
end
|
53
|
+
else
|
54
|
+
# Regular field rendering
|
55
|
+
component = EasyAdmin::Field.render(
|
56
|
+
field[:type],
|
57
|
+
action: action,
|
58
|
+
field: field,
|
59
|
+
value: value,
|
60
|
+
record: record,
|
61
|
+
form: form
|
62
|
+
)
|
12
63
|
|
13
|
-
|
64
|
+
component.call.html_safe
|
65
|
+
end
|
14
66
|
end
|
15
67
|
|
16
68
|
def field_component(field, action:, value: nil, record: nil, form: nil)
|
@@ -6,9 +6,7 @@ export default class extends Controller {
|
|
6
6
|
emit() {
|
7
7
|
const eventName = this.eventValue
|
8
8
|
const eventDetail = this.hasDetailValue ? this.detailValue : {}
|
9
|
-
|
10
|
-
console.log(`Event emitter dispatching: ${eventName}`, eventDetail)
|
11
|
-
|
9
|
+
|
12
10
|
const customEvent = new CustomEvent(eventName, {
|
13
11
|
detail: eventDetail,
|
14
12
|
bubbles: true
|
@@ -16,4 +14,4 @@ export default class extends Controller {
|
|
16
14
|
|
17
15
|
document.dispatchEvent(customEvent)
|
18
16
|
}
|
19
|
-
}
|
17
|
+
}
|
@@ -91,7 +91,6 @@ export default class extends Controller {
|
|
91
91
|
return
|
92
92
|
}
|
93
93
|
|
94
|
-
console.log('Loading next page:', this.urlValue)
|
95
94
|
this.isLoading = true
|
96
95
|
this.showLoading()
|
97
96
|
|
@@ -104,10 +103,6 @@ export default class extends Controller {
|
|
104
103
|
// Turbo streams will be automatically processed
|
105
104
|
// Dispatch event to let other controllers know more content was loaded
|
106
105
|
this.dispatch("loaded", { detail: { url: this.urlValue } })
|
107
|
-
console.log('Loaded successfully, new state will be:', {
|
108
|
-
currentUrl: this.urlValue,
|
109
|
-
hasMore: this.hasMoreValue
|
110
|
-
})
|
111
106
|
} else {
|
112
107
|
// Let the server handle error responses via turbo streams
|
113
108
|
this.dispatch("error", { detail: { response } })
|
@@ -148,11 +143,6 @@ export default class extends Controller {
|
|
148
143
|
|
149
144
|
// Debug method to log current state
|
150
145
|
logState() {
|
151
|
-
console.log('InfiniteScroll State:', {
|
152
|
-
urlValue: this.urlValue,
|
153
|
-
hasMoreValue: this.hasMoreValue,
|
154
|
-
isLoading: this.isLoading
|
155
|
-
})
|
156
146
|
}
|
157
147
|
|
158
148
|
showEndMessage() {
|
@@ -59,15 +59,12 @@ export default class extends Controller {
|
|
59
59
|
const jsonData = this.editor.get()
|
60
60
|
const jsonString = JSON.stringify(jsonData)
|
61
61
|
this.hiddenFieldTarget.value = jsonString
|
62
|
-
console.log('JSON Editor: Updated hidden field with:', jsonString)
|
63
62
|
} catch (error) {
|
64
|
-
console.warn('JSON Editor: Invalid JSON, not updating hidden field:', error)
|
65
63
|
// In case of error, try to get the text content
|
66
64
|
try {
|
67
65
|
if (this.editor.mode === 'code') {
|
68
66
|
const text = this.editor.getText()
|
69
67
|
this.hiddenFieldTarget.value = text
|
70
|
-
console.log('JSON Editor: Updated hidden field with raw text:', text)
|
71
68
|
}
|
72
69
|
} catch (textError) {
|
73
70
|
console.warn('JSON Editor: Could not get text content:', textError)
|
@@ -85,4 +82,4 @@ export default class extends Controller {
|
|
85
82
|
this.editor.set(data)
|
86
83
|
}
|
87
84
|
}
|
88
|
-
}
|
85
|
+
}
|
@@ -0,0 +1,227 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["permissionCard", "hiddenInput"]
|
5
|
+
static values = {
|
6
|
+
userId: Number
|
7
|
+
}
|
8
|
+
|
9
|
+
connect() {
|
10
|
+
console.log("🎯 PermissionToggleController connected")
|
11
|
+
console.log("🎯 User ID:", this.userIdValue)
|
12
|
+
|
13
|
+
// Debug: Log all permission cards found
|
14
|
+
const cards = this.permissionCardTargets
|
15
|
+
console.log("🎯 Found", cards.length, "permission cards")
|
16
|
+
|
17
|
+
cards.forEach((card, index) => {
|
18
|
+
console.log(`🎯 Card ${index}:`, {
|
19
|
+
permission: card.dataset.permissionName,
|
20
|
+
granted: card.dataset.granted,
|
21
|
+
resourceType: card.dataset.resourceType
|
22
|
+
})
|
23
|
+
})
|
24
|
+
|
25
|
+
// Debug: Log all hidden inputs found
|
26
|
+
const hiddenInputs = this.element.querySelectorAll('input[type="hidden"][data-permission-toggle-target="hiddenInput"]')
|
27
|
+
console.log("🎯 Found", hiddenInputs.length, "hidden inputs")
|
28
|
+
|
29
|
+
hiddenInputs.forEach((input, index) => {
|
30
|
+
console.log(`🎯 Hidden input ${index}:`, {
|
31
|
+
name: input.name,
|
32
|
+
value: input.value
|
33
|
+
})
|
34
|
+
})
|
35
|
+
}
|
36
|
+
|
37
|
+
togglePermission(event) {
|
38
|
+
event.preventDefault()
|
39
|
+
|
40
|
+
const card = event.currentTarget
|
41
|
+
const permissionName = card.dataset.permissionName
|
42
|
+
const currentlyGranted = card.dataset.granted === "true"
|
43
|
+
const newGrantedState = !currentlyGranted
|
44
|
+
|
45
|
+
console.log("🎯 Toggling permission:", {
|
46
|
+
permission: permissionName,
|
47
|
+
wasGranted: currentlyGranted,
|
48
|
+
nowGranted: newGrantedState
|
49
|
+
})
|
50
|
+
|
51
|
+
// Update the card UI immediately
|
52
|
+
this.updateCardUI(card, newGrantedState)
|
53
|
+
|
54
|
+
// Update the hidden input field for form submission
|
55
|
+
this.updateHiddenInput(card, newGrantedState)
|
56
|
+
|
57
|
+
// Show notification for feedback
|
58
|
+
this.showNotification(
|
59
|
+
`Permission ${newGrantedState ? 'granted' : 'revoked'}`,
|
60
|
+
newGrantedState ? "success" : "warning"
|
61
|
+
)
|
62
|
+
}
|
63
|
+
|
64
|
+
toggleAllForResource(event) {
|
65
|
+
event.preventDefault()
|
66
|
+
|
67
|
+
const resourceType = event.currentTarget.dataset.resourceType
|
68
|
+
const resourceCards = this.permissionCardTargets.filter(card =>
|
69
|
+
card.dataset.resourceType === resourceType
|
70
|
+
)
|
71
|
+
|
72
|
+
// Determine if we should grant all or revoke all
|
73
|
+
// If any permission is not granted, grant all. Otherwise, revoke all.
|
74
|
+
const hasAnyDenied = resourceCards.some(card => card.dataset.granted === "false")
|
75
|
+
const newState = hasAnyDenied
|
76
|
+
|
77
|
+
resourceCards.forEach(card => {
|
78
|
+
if ((card.dataset.granted === "true") !== newState) {
|
79
|
+
// Only toggle cards that need to change
|
80
|
+
const permissionId = card.dataset.permissionId
|
81
|
+
const permissionName = card.dataset.permissionName
|
82
|
+
|
83
|
+
// Update UI
|
84
|
+
this.updateCardUI(card, newState)
|
85
|
+
|
86
|
+
// Update hidden input
|
87
|
+
this.updateHiddenInput(card, newState)
|
88
|
+
}
|
89
|
+
})
|
90
|
+
|
91
|
+
this.showNotification(
|
92
|
+
`${newState ? 'Granted' : 'Revoked'} all ${resourceType.replace(/_/g, ' ')} permissions (UI only)`,
|
93
|
+
newState ? "success" : "warning"
|
94
|
+
)
|
95
|
+
}
|
96
|
+
|
97
|
+
updateCardUI(card, isGranted) {
|
98
|
+
// Update data attribute
|
99
|
+
card.dataset.granted = isGranted.toString()
|
100
|
+
|
101
|
+
// Update CSS classes
|
102
|
+
card.className = `permission-card cursor-pointer transition-all duration-200 ${this.getCardClasses(isGranted)}`
|
103
|
+
|
104
|
+
// Update icon
|
105
|
+
const iconContainer = card.querySelector('.flex-shrink-0')
|
106
|
+
if (iconContainer) {
|
107
|
+
iconContainer.innerHTML = this.getPermissionIconSVG(isGranted)
|
108
|
+
}
|
109
|
+
|
110
|
+
// Update text colors
|
111
|
+
const actionSpan = card.querySelector('.text-sm.font-medium')
|
112
|
+
if (actionSpan) {
|
113
|
+
actionSpan.className = `text-sm font-medium ${isGranted ? 'text-green-900' : 'text-gray-900'} capitalize`
|
114
|
+
}
|
115
|
+
|
116
|
+
// Update badge colors
|
117
|
+
const badge = card.querySelector('.px-2.py-1.rounded.text-xs.font-medium')
|
118
|
+
if (badge) {
|
119
|
+
badge.className = `ml-2 inline-flex items-center px-2 py-1 rounded text-xs font-medium ${this.getPermissionBadgeClasses(isGranted)}`
|
120
|
+
}
|
121
|
+
|
122
|
+
// Update description color
|
123
|
+
const description = card.querySelector('.leading-relaxed')
|
124
|
+
if (description) {
|
125
|
+
description.className = `text-xs ${isGranted ? 'text-green-700' : 'text-gray-600'} leading-relaxed`
|
126
|
+
}
|
127
|
+
|
128
|
+
// Update technical name color
|
129
|
+
const technicalName = card.querySelector('.font-mono')
|
130
|
+
if (technicalName) {
|
131
|
+
technicalName.className = `text-xs ${isGranted ? 'text-green-600' : 'text-gray-500'} font-mono mt-1`
|
132
|
+
}
|
133
|
+
|
134
|
+
// Update status indicator
|
135
|
+
const statusIndicator = card.querySelector('.rounded-full')
|
136
|
+
if (statusIndicator) {
|
137
|
+
statusIndicator.textContent = isGranted ? "Granted" : "Denied"
|
138
|
+
statusIndicator.className = `inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${this.getStatusClasses(isGranted)}`
|
139
|
+
}
|
140
|
+
}
|
141
|
+
|
142
|
+
// TODO: Implement backend integration
|
143
|
+
// async sendToggleRequest(permissionId, permissionName, isGranted) {
|
144
|
+
// const response = await fetch('/admin/permissions/toggle', {
|
145
|
+
// method: 'POST',
|
146
|
+
// headers: {
|
147
|
+
// 'Content-Type': 'application/json',
|
148
|
+
// 'X-CSRF-Token': this.getCSRFToken(),
|
149
|
+
// 'Accept': 'application/json'
|
150
|
+
// },
|
151
|
+
// body: JSON.stringify({
|
152
|
+
// user_id: this.userIdValue,
|
153
|
+
// permission_id: permissionId,
|
154
|
+
// permission_name: permissionName,
|
155
|
+
// granted: isGranted
|
156
|
+
// })
|
157
|
+
// })
|
158
|
+
|
159
|
+
// if (!response.ok) {
|
160
|
+
// throw new Error(`HTTP error! status: ${response.status}`)
|
161
|
+
// }
|
162
|
+
|
163
|
+
// return await response.json()
|
164
|
+
// }
|
165
|
+
|
166
|
+
updateHiddenInput(card, isGranted) {
|
167
|
+
// Find the hidden input within this card
|
168
|
+
const hiddenInput = card.querySelector('input[type="hidden"][data-permission-toggle-target="hiddenInput"]')
|
169
|
+
if (hiddenInput) {
|
170
|
+
const oldValue = hiddenInput.value
|
171
|
+
hiddenInput.value = isGranted.toString()
|
172
|
+
console.log("🎯 Updated hidden input:", {
|
173
|
+
name: hiddenInput.name,
|
174
|
+
oldValue: oldValue,
|
175
|
+
newValue: hiddenInput.value
|
176
|
+
})
|
177
|
+
} else {
|
178
|
+
console.warn(`🎯 No hidden input found for permission card: ${card.dataset.permissionName}`)
|
179
|
+
}
|
180
|
+
}
|
181
|
+
|
182
|
+
getCSRFToken() {
|
183
|
+
const metaTag = document.querySelector('meta[name="csrf-token"]')
|
184
|
+
return metaTag ? metaTag.getAttribute('content') : ''
|
185
|
+
}
|
186
|
+
|
187
|
+
showNotification(message, type = "info") {
|
188
|
+
// Try to use existing notification system
|
189
|
+
const event = new CustomEvent('notification:show', {
|
190
|
+
detail: { message, type }
|
191
|
+
})
|
192
|
+
window.dispatchEvent(event)
|
193
|
+
}
|
194
|
+
|
195
|
+
// Helper methods for CSS classes (matching the Ruby component)
|
196
|
+
getCardClasses(isGranted) {
|
197
|
+
if (isGranted) {
|
198
|
+
return "bg-green-50 border border-green-200 hover:border-green-300 hover:bg-green-100"
|
199
|
+
} else {
|
200
|
+
return "bg-red-50 border border-red-200 hover:border-red-300 hover:bg-red-100"
|
201
|
+
}
|
202
|
+
}
|
203
|
+
|
204
|
+
getPermissionBadgeClasses(isGranted) {
|
205
|
+
if (isGranted) {
|
206
|
+
return "bg-green-100 text-green-800"
|
207
|
+
} else {
|
208
|
+
return "bg-red-100 text-red-800"
|
209
|
+
}
|
210
|
+
}
|
211
|
+
|
212
|
+
getStatusClasses(isGranted) {
|
213
|
+
if (isGranted) {
|
214
|
+
return "bg-green-100 text-green-800"
|
215
|
+
} else {
|
216
|
+
return "bg-red-100 text-red-800"
|
217
|
+
}
|
218
|
+
}
|
219
|
+
|
220
|
+
getPermissionIconSVG(isGranted) {
|
221
|
+
if (isGranted) {
|
222
|
+
return '<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>'
|
223
|
+
} else {
|
224
|
+
return '<svg class="w-5 h-5 text-red-500 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>'
|
225
|
+
}
|
226
|
+
}
|
227
|
+
}
|
@@ -0,0 +1,93 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
export default class extends Controller {
|
4
|
+
static targets = ["preview"]
|
5
|
+
static values = {
|
6
|
+
roles: Array
|
7
|
+
}
|
8
|
+
|
9
|
+
updatePreview(event) {
|
10
|
+
const selectedRoleId = parseInt(event.target.value)
|
11
|
+
const selectedRole = this.rolesValue.find(role => role.id === selectedRoleId)
|
12
|
+
|
13
|
+
if (selectedRole && this.hasPreviewTarget) {
|
14
|
+
this.renderRolePreview(selectedRole)
|
15
|
+
} else if (this.hasPreviewTarget) {
|
16
|
+
this.clearPreview()
|
17
|
+
}
|
18
|
+
}
|
19
|
+
|
20
|
+
renderRolePreview(role) {
|
21
|
+
const previewHTML = this.buildPreviewHTML(role)
|
22
|
+
this.previewTarget.innerHTML = previewHTML
|
23
|
+
this.previewTarget.style.display = 'block'
|
24
|
+
}
|
25
|
+
|
26
|
+
clearPreview() {
|
27
|
+
this.previewTarget.innerHTML = ''
|
28
|
+
this.previewTarget.style.display = 'none'
|
29
|
+
}
|
30
|
+
|
31
|
+
buildPreviewHTML(role) {
|
32
|
+
const permissionsCount = Object.values(role.permissions || {}).flat().length
|
33
|
+
|
34
|
+
let html = `
|
35
|
+
<div class="mb-4">
|
36
|
+
<h3 class="text-lg font-medium text-gray-900 mb-2">Role Permissions Preview</h3>
|
37
|
+
<p class="text-sm text-gray-600">
|
38
|
+
Permissions that will be granted with the <strong>${role.name}</strong> role:
|
39
|
+
</p>
|
40
|
+
</div>
|
41
|
+
`
|
42
|
+
|
43
|
+
if (permissionsCount === 0) {
|
44
|
+
html += `
|
45
|
+
<div class="text-center py-6">
|
46
|
+
<svg class="w-8 h-8 text-gray-400 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
47
|
+
<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>
|
48
|
+
</svg>
|
49
|
+
<h4 class="text-md font-medium text-gray-900 mb-1">No Permissions</h4>
|
50
|
+
<p class="text-gray-600">This role has no permissions assigned.</p>
|
51
|
+
</div>
|
52
|
+
`
|
53
|
+
} else {
|
54
|
+
html += '<div class="space-y-4">'
|
55
|
+
|
56
|
+
Object.entries(role.permissions || {}).forEach(([resourceType, permissions]) => {
|
57
|
+
html += `
|
58
|
+
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
59
|
+
<div class="flex items-center mb-3">
|
60
|
+
<h4 class="text-md font-medium text-gray-900 capitalize">${resourceType.replace(/_/g, ' ')}</h4>
|
61
|
+
<span class="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
62
|
+
${permissions.length} permissions
|
63
|
+
</span>
|
64
|
+
</div>
|
65
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
66
|
+
`
|
67
|
+
|
68
|
+
permissions.forEach(permission => {
|
69
|
+
html += `
|
70
|
+
<div class="flex items-center p-2 bg-green-50 border border-green-200 rounded">
|
71
|
+
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
72
|
+
<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>
|
73
|
+
</svg>
|
74
|
+
<div class="flex-1 min-w-0">
|
75
|
+
<span class="text-sm font-medium text-gray-900 capitalize">${permission.action.replace(/_/g, ' ')}</span>
|
76
|
+
${permission.description ? `<p class="text-xs text-gray-600 mt-1">${permission.description}</p>` : ''}
|
77
|
+
</div>
|
78
|
+
</div>
|
79
|
+
`
|
80
|
+
})
|
81
|
+
|
82
|
+
html += `
|
83
|
+
</div>
|
84
|
+
</div>
|
85
|
+
`
|
86
|
+
})
|
87
|
+
|
88
|
+
html += '</div>'
|
89
|
+
}
|
90
|
+
|
91
|
+
return html
|
92
|
+
}
|
93
|
+
}
|
@@ -117,8 +117,7 @@ export default class extends Controller {
|
|
117
117
|
|
118
118
|
filter(event) {
|
119
119
|
const searchTerm = event.target.value
|
120
|
-
|
121
|
-
|
120
|
+
|
122
121
|
if (this.suggestValue) {
|
123
122
|
// For suggest mode, use debounced API search
|
124
123
|
this.debouncedSuggestSearch(searchTerm)
|
@@ -4,8 +4,6 @@ export default class extends Controller {
|
|
4
4
|
static targets = ["container"]
|
5
5
|
|
6
6
|
connect() {
|
7
|
-
console.log('Settings sidebar controller connected')
|
8
|
-
console.log('Container target:', this.containerTarget)
|
9
7
|
this.isOpen = false
|
10
8
|
// Listen for global events to open/close
|
11
9
|
document.addEventListener('settings:open', this.open.bind(this))
|
@@ -14,7 +12,6 @@ export default class extends Controller {
|
|
14
12
|
|
15
13
|
|
16
14
|
open() {
|
17
|
-
console.log('Settings sidebar open() called')
|
18
15
|
this.isOpen = true
|
19
16
|
|
20
17
|
// Add subtle bounce animation and slide in
|
@@ -183,4 +180,4 @@ export default class extends Controller {
|
|
183
180
|
document.removeEventListener('settings:close', this.close.bind(this))
|
184
181
|
document.removeEventListener('keydown', this.handleKeydown)
|
185
182
|
}
|
186
|
-
}
|
183
|
+
}
|
@@ -26,6 +26,8 @@ import RowModalController from './controllers/row_modal_controller'
|
|
26
26
|
import RowActionController from './controllers/row_action_controller'
|
27
27
|
import JsoneditorController from './controllers/jsoneditor_controller'
|
28
28
|
import VersionRevertController from './controllers/version_revert_controller'
|
29
|
+
import RolePreviewController from './controllers/role_preview_controller'
|
30
|
+
import PermissionToggleController from './controllers/permission_toggle_controller'
|
29
31
|
|
30
32
|
// Register controllers
|
31
33
|
application.register('sidebar', SidebarController)
|
@@ -53,4 +55,6 @@ application.register('context-menu', ContextMenuController)
|
|
53
55
|
application.register('row-modal', RowModalController)
|
54
56
|
application.register('row-action', RowActionController)
|
55
57
|
application.register('jsoneditor', JsoneditorController)
|
56
|
-
application.register('version-revert', VersionRevertController)
|
58
|
+
application.register('version-revert', VersionRevertController)
|
59
|
+
application.register('role-preview', RolePreviewController)
|
60
|
+
application.register('permission-toggle', PermissionToggleController)
|
@@ -7,6 +7,12 @@ module EasyAdmin
|
|
7
7
|
:recoverable, :rememberable, :validatable,
|
8
8
|
:trackable, :lockable, :timeoutable
|
9
9
|
|
10
|
+
# EasyAdmin Permissions
|
11
|
+
include EasyAdmin::Permissions::UserExtensions
|
12
|
+
|
13
|
+
# Direct role association (each admin user has one role)
|
14
|
+
belongs_to :role, class_name: 'EasyAdmin::Permissions::Role', optional: true
|
15
|
+
|
10
16
|
# Validations
|
11
17
|
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
12
18
|
validates :first_name, :last_name, presence: true
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class AdminUserPolicy < ApplicationPolicy
|
2
|
+
# Users can always view their own profile
|
3
|
+
def show?
|
4
|
+
own_record? || super
|
5
|
+
end
|
6
|
+
|
7
|
+
# Users can update their own basic information
|
8
|
+
def update?
|
9
|
+
own_record? || super
|
10
|
+
end
|
11
|
+
|
12
|
+
# Only users with permissions can create other users
|
13
|
+
def create?
|
14
|
+
user_has_permission?("users:create")
|
15
|
+
end
|
16
|
+
|
17
|
+
# Only users with permissions can delete other users
|
18
|
+
def destroy?
|
19
|
+
!own_record? && user_has_permission?("users:delete")
|
20
|
+
end
|
21
|
+
|
22
|
+
# Role management permissions
|
23
|
+
def assign_roles?
|
24
|
+
user_has_permission?("users:manage_roles")
|
25
|
+
end
|
26
|
+
|
27
|
+
def remove_roles?
|
28
|
+
user_has_permission?("users:manage_roles")
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def own_record?
|
34
|
+
user && record && user.id == record.id
|
35
|
+
end
|
36
|
+
end
|