easy-admin-rails 0.1.14 → 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 (93) 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/components/easy_admin/versions/diff_modal_component.rb +5 -1
  13. data/app/controllers/easy_admin/application_controller.rb +131 -1
  14. data/app/controllers/easy_admin/batch_actions_controller.rb +27 -0
  15. data/app/controllers/easy_admin/concerns/belongs_to_editing.rb +201 -0
  16. data/app/controllers/easy_admin/concerns/inline_field_editing.rb +297 -0
  17. data/app/controllers/easy_admin/concerns/resource_authorization.rb +55 -0
  18. data/app/controllers/easy_admin/concerns/resource_filtering.rb +178 -0
  19. data/app/controllers/easy_admin/concerns/resource_loading.rb +149 -0
  20. data/app/controllers/easy_admin/concerns/resource_pagination.rb +135 -0
  21. data/app/controllers/easy_admin/dashboard_controller.rb +2 -1
  22. data/app/controllers/easy_admin/dashboards_controller.rb +6 -40
  23. data/app/controllers/easy_admin/resources_controller.rb +13 -762
  24. data/app/controllers/easy_admin/row_actions_controller.rb +25 -0
  25. data/app/helpers/easy_admin/fields_helper.rb +61 -9
  26. data/app/javascript/easy_admin/controllers/event_emitter_controller.js +2 -4
  27. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +0 -10
  28. data/app/javascript/easy_admin/controllers/jsoneditor_controller.js +1 -4
  29. data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +227 -0
  30. data/app/javascript/easy_admin/controllers/role_preview_controller.js +93 -0
  31. data/app/javascript/easy_admin/controllers/select_field_controller.js +1 -2
  32. data/app/javascript/easy_admin/controllers/settings_button_controller.js +1 -2
  33. data/app/javascript/easy_admin/controllers/settings_sidebar_controller.js +1 -4
  34. data/app/javascript/easy_admin/controllers/turbo_stream_redirect.js +0 -2
  35. data/app/javascript/easy_admin/controllers.js +5 -1
  36. data/app/models/easy_admin/admin_user.rb +6 -0
  37. data/app/policies/admin_user_policy.rb +36 -0
  38. data/app/policies/application_policy.rb +83 -0
  39. data/app/views/easy_admin/application/authorization_failure.turbo_stream.erb +8 -0
  40. data/app/views/easy_admin/dashboards/card.html.erb +5 -0
  41. data/app/views/easy_admin/dashboards/card.turbo_stream.erb +7 -0
  42. data/app/views/easy_admin/dashboards/card_error.html.erb +3 -0
  43. data/app/views/easy_admin/dashboards/card_error.turbo_stream.erb +5 -0
  44. data/app/views/easy_admin/dashboards/show.turbo_stream.erb +7 -0
  45. data/app/views/easy_admin/resources/belongs_to_edit_attached.html.erb +6 -0
  46. data/app/views/easy_admin/resources/belongs_to_edit_attached.turbo_stream.erb +8 -0
  47. data/app/views/easy_admin/resources/belongs_to_reattach.html.erb +5 -0
  48. data/app/views/easy_admin/resources/edit.html.erb +1 -1
  49. data/app/views/easy_admin/resources/edit_field.html.erb +5 -0
  50. data/app/views/easy_admin/resources/edit_field.turbo_stream.erb +7 -0
  51. data/app/views/easy_admin/resources/index.html.erb +1 -1
  52. data/app/views/easy_admin/resources/index_frame.html.erb +8 -142
  53. data/app/views/easy_admin/resources/update_belongs_to_attached.turbo_stream.erb +25 -0
  54. data/app/views/layouts/easy_admin/application.html.erb +15 -2
  55. data/config/initializers/easy_admin_permissions.rb +73 -0
  56. data/db/seeds/easy_admin_permissions.rb +121 -0
  57. data/lib/easy-admin-rails.rb +2 -0
  58. data/lib/easy_admin/permissions/component.rb +168 -0
  59. data/lib/easy_admin/permissions/configuration.rb +37 -0
  60. data/lib/easy_admin/permissions/controller.rb +164 -0
  61. data/lib/easy_admin/permissions/dsl.rb +180 -0
  62. data/lib/easy_admin/permissions/models.rb +44 -0
  63. data/lib/easy_admin/permissions/permission_denied_component.rb +121 -0
  64. data/lib/easy_admin/permissions/resource_permissions.rb +231 -0
  65. data/lib/easy_admin/permissions/role_definition.rb +45 -0
  66. data/lib/easy_admin/permissions/role_denied_component.rb +159 -0
  67. data/lib/easy_admin/permissions/role_dsl.rb +73 -0
  68. data/lib/easy_admin/permissions/user_extensions.rb +129 -0
  69. data/lib/easy_admin/permissions.rb +113 -0
  70. data/lib/easy_admin/resource/base.rb +119 -0
  71. data/lib/easy_admin/resource/configuration.rb +148 -0
  72. data/lib/easy_admin/resource/dsl.rb +117 -0
  73. data/lib/easy_admin/resource/field_registry.rb +189 -0
  74. data/lib/easy_admin/resource/form_builder.rb +123 -0
  75. data/lib/easy_admin/resource/layout_builder.rb +249 -0
  76. data/lib/easy_admin/resource/scope_manager.rb +252 -0
  77. data/lib/easy_admin/resource/show_builder.rb +359 -0
  78. data/lib/easy_admin/resource.rb +8 -835
  79. data/lib/easy_admin/resource_modules.rb +11 -0
  80. data/lib/easy_admin/version.rb +1 -1
  81. data/lib/generators/easy_admin/permissions/install_generator.rb +90 -0
  82. data/lib/generators/easy_admin/permissions/templates/initializers/permissions.rb +37 -0
  83. data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +27 -0
  84. data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +6 -0
  85. data/lib/generators/easy_admin/permissions/templates/models/permission.rb +9 -0
  86. data/lib/generators/easy_admin/permissions/templates/models/role.rb +9 -0
  87. data/lib/generators/easy_admin/permissions/templates/models/role_permission.rb +9 -0
  88. data/lib/generators/easy_admin/permissions/templates/models/user_role.rb +9 -0
  89. data/lib/generators/easy_admin/permissions/templates/policies/application_policy.rb +47 -0
  90. data/lib/generators/easy_admin/permissions/templates/policies/user_policy.rb +36 -0
  91. data/lib/generators/easy_admin/permissions/templates/seeds/permissions.rb +89 -0
  92. metadata +62 -5
  93. 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
- component = EasyAdmin::Field.render(
5
- field[:type],
6
- action: action,
7
- field: field,
8
- value: value,
9
- record: record,
10
- form: form
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
- component.call.html_safe
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
- console.log('Filter called with term:', searchTerm, 'Suggest mode:', this.suggestValue)
121
-
120
+
122
121
  if (this.suggestValue) {
123
122
  // For suggest mode, use debounced API search
124
123
  this.debouncedSuggestSearch(searchTerm)
@@ -2,7 +2,6 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
4
  open() {
5
- console.log('Settings button clicked - dispatching settings:open event')
6
5
  document.dispatchEvent(new CustomEvent('settings:open'))
7
6
  }
8
- }
7
+ }
@@ -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
+ }
@@ -2,8 +2,6 @@ import { Turbo } from "@hotwired/turbo-rails"
2
2
 
3
3
  Turbo.StreamActions.redirect = function() {
4
4
  const url = this.getAttribute("url")
5
- console.log("Redirect URL:", url)
6
- console.log("Element:", this)
7
5
 
8
6
  Turbo.visit(url, { frame: '_top', action: 'advance' })
9
7
  }
@@ -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