easy-admin-rails 0.2.5 → 0.2.7

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +95 -0
  3. data/app/assets/builds/easy_admin.base.js.map +3 -3
  4. data/app/assets/builds/easy_admin.css +226 -0
  5. data/app/components/easy_admin/fields/form/belongs_to_component.rb +0 -1
  6. data/app/components/easy_admin/form_layout_component.rb +553 -0
  7. data/app/components/easy_admin/navbar_component.rb +19 -4
  8. data/app/components/easy_admin/permissions/user_role_permissions_component.rb +1 -3
  9. data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
  10. data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
  11. data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
  12. data/app/components/easy_admin/profile/settings_component.rb +103 -0
  13. data/app/components/easy_admin/show_layout_component.rb +694 -24
  14. data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
  15. data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
  16. data/app/components/easy_admin/two_factor/status_component.rb +92 -0
  17. data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
  18. data/app/controllers/easy_admin/application_controller.rb +10 -5
  19. data/app/controllers/easy_admin/batch_actions_controller.rb +0 -1
  20. data/app/controllers/easy_admin/concerns/inline_field_editing.rb +4 -11
  21. data/app/controllers/easy_admin/concerns/resource_loading.rb +10 -9
  22. data/app/controllers/easy_admin/concerns/resource_pagination.rb +3 -0
  23. data/app/controllers/easy_admin/dashboards_controller.rb +0 -1
  24. data/app/controllers/easy_admin/profile_controller.rb +25 -0
  25. data/app/controllers/easy_admin/resources_controller.rb +1 -5
  26. data/app/controllers/easy_admin/row_actions_controller.rb +1 -4
  27. data/app/controllers/easy_admin/sessions_controller.rb +107 -1
  28. data/app/helpers/easy_admin/fields_helper.rb +8 -22
  29. data/app/javascript/easy_admin/controllers/infinite_scroll_controller.js +12 -0
  30. data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
  31. data/app/javascript/easy_admin/controllers.js +3 -1
  32. data/app/models/easy_admin/admin_user.rb +3 -0
  33. data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
  34. data/app/views/easy_admin/profile/change_password.html.erb +24 -0
  35. data/app/views/easy_admin/profile/index.html.erb +1 -0
  36. data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
  37. data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
  38. data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
  39. data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
  40. data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
  41. data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
  42. data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
  43. data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
  44. data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
  45. data/app/views/easy_admin/resources/edit.html.erb +2 -2
  46. data/app/views/easy_admin/resources/new.html.erb +2 -2
  47. data/app/views/easy_admin/resources/show.html.erb +3 -1
  48. data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
  49. data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
  50. data/config/routes.rb +20 -1
  51. data/lib/easy-admin-rails.rb +1 -0
  52. data/lib/easy_admin/field.rb +3 -2
  53. data/lib/easy_admin/layouts/builders/base_layout_builder.rb +245 -0
  54. data/lib/easy_admin/layouts/builders/form_layout_builder.rb +208 -0
  55. data/lib/easy_admin/layouts/builders/index_layout_builder.rb +22 -0
  56. data/lib/easy_admin/layouts/builders/show_layout_builder.rb +199 -0
  57. data/lib/easy_admin/layouts/dsl.rb +200 -0
  58. data/lib/easy_admin/layouts/layout_context.rb +189 -0
  59. data/lib/easy_admin/layouts/nodes/base_node.rb +88 -0
  60. data/lib/easy_admin/layouts/nodes/divider.rb +27 -0
  61. data/lib/easy_admin/layouts/nodes/field_node.rb +57 -0
  62. data/lib/easy_admin/layouts/nodes/grid.rb +60 -0
  63. data/lib/easy_admin/layouts/nodes/render_node.rb +41 -0
  64. data/lib/easy_admin/layouts/nodes/root.rb +25 -0
  65. data/lib/easy_admin/layouts/nodes/section.rb +46 -0
  66. data/lib/easy_admin/layouts/nodes/spacer.rb +17 -0
  67. data/lib/easy_admin/layouts/nodes/stubs.rb +109 -0
  68. data/lib/easy_admin/layouts/nodes/tab.rb +40 -0
  69. data/lib/easy_admin/layouts/nodes/tabs.rb +40 -0
  70. data/lib/easy_admin/layouts.rb +28 -0
  71. data/lib/easy_admin/permissions/resource_permissions.rb +1 -5
  72. data/lib/easy_admin/resource/base.rb +2 -2
  73. data/lib/easy_admin/resource/dsl.rb +2 -11
  74. data/lib/easy_admin/resource/field_registry.rb +58 -2
  75. data/lib/easy_admin/resource.rb +0 -9
  76. data/lib/easy_admin/resource_modules.rb +21 -4
  77. data/lib/easy_admin/two_factor_authentication.rb +156 -0
  78. data/lib/easy_admin/version.rb +1 -1
  79. data/lib/generators/easy_admin/permissions/install_generator.rb +0 -10
  80. data/lib/generators/easy_admin/permissions/templates/migrations/create_permission_tables.rb +33 -3
  81. data/lib/generators/easy_admin/two_factor/templates/README +29 -0
  82. data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
  83. data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
  84. metadata +49 -9
  85. data/lib/easy_admin/resource/form_builder.rb +0 -123
  86. data/lib/easy_admin/resource/layout_builder.rb +0 -249
  87. data/lib/easy_admin/resource/show_builder.rb +0 -359
  88. data/lib/generators/easy_admin/permissions/templates/migrations/update_users_for_permissions.rb +0 -6
  89. data/lib/generators/easy_admin/rbac/rbac_generator.rb +0 -244
  90. data/lib/generators/easy_admin/rbac/templates/add_rbac_to_admin_users.rb +0 -23
  91. data/lib/generators/easy_admin/rbac/templates/super_admin.rb +0 -34
@@ -4,16 +4,116 @@ module EasyAdmin
4
4
 
5
5
  # GET /easy_admin/sign_in
6
6
  def new
7
+ # Clear any pending 2FA session
8
+ session.delete(:pending_2fa_user_id)
7
9
  super
8
10
  end
9
11
 
10
12
  # POST /easy_admin/sign_in
11
13
  def create
14
+ # First, try to authenticate with email/password
15
+ self.resource = warden.authenticate!(auth_options)
16
+
17
+ if resource
18
+ # Check if 2FA is required for this user
19
+ if resource.two_factor_enabled?
20
+ # Store user ID in session for 2FA verification
21
+ session[:pending_2fa_user_id] = resource.id
22
+
23
+ # Don't sign in yet - redirect to 2FA verification page
24
+ redirect_to two_factor_verification_path
25
+ else
26
+ # No 2FA required, proceed with normal sign in
27
+ set_flash_message!(:notice, :signed_in)
28
+ sign_in(resource_name, resource)
29
+ yield resource if block_given?
30
+ respond_with resource, location: after_sign_in_path_for(resource)
31
+ end
32
+ else
33
+ # Authentication failed
34
+ super
35
+ end
36
+ rescue => e
37
+ # Handle authentication errors
12
38
  super
13
39
  end
40
+
41
+ # GET /easy_admin/two_factor_verification
42
+ def two_factor_verification
43
+ user_id = session[:pending_2fa_user_id]
44
+
45
+ unless user_id
46
+ redirect_to new_admin_user_session_path, alert: "Session expired. Please sign in again."
47
+ return
48
+ end
49
+
50
+ @user = EasyAdmin::AdminUser.find_by(id: user_id)
51
+
52
+ unless @user&.two_factor_enabled?
53
+ session.delete(:pending_2fa_user_id)
54
+ redirect_to new_admin_user_session_path, alert: "Invalid session. Please sign in again."
55
+ return
56
+ end
57
+ end
58
+
59
+ # GET /easy_admin/cancel_2fa
60
+ def cancel_2fa
61
+ # Clear the pending 2FA session
62
+ session.delete(:pending_2fa_user_id)
63
+
64
+ # Sign out the user completely
65
+ sign_out(current_admin_user) if current_admin_user
66
+
67
+ # Redirect to sign in with a message
68
+ redirect_to new_admin_user_session_path, notice: "2FA verification cancelled. Please sign in again."
69
+ end
70
+
71
+ # POST /easy_admin/verify_2fa
72
+ def verify_2fa
73
+ user_id = session[:pending_2fa_user_id]
74
+
75
+ unless user_id
76
+ redirect_to new_admin_user_session_path, alert: "Session expired. Please sign in again."
77
+ return
78
+ end
79
+
80
+ user = EasyAdmin::AdminUser.find_by(id: user_id)
81
+
82
+ unless user&.two_factor_enabled?
83
+ session.delete(:pending_2fa_user_id)
84
+ redirect_to new_admin_user_session_path, alert: "Invalid session. Please sign in again."
85
+ return
86
+ end
87
+
88
+ otp_code = params[:otp_code]&.strip
89
+
90
+ if otp_code.present? && user.validate_and_consume_otp!(otp_code)
91
+ # 2FA verification successful
92
+ session.delete(:pending_2fa_user_id)
93
+ set_flash_message!(:notice, :signed_in)
94
+ sign_in(resource_name, user)
95
+
96
+ redirect_to after_sign_in_path_for(user)
97
+ else
98
+ # 2FA verification failed
99
+ @user = user # Make sure @user is available for the view
100
+
101
+ respond_to do |format|
102
+ format.html do
103
+ flash.now[:alert] = "Invalid authentication code. Please try again."
104
+ render :two_factor_verification
105
+ end
106
+ format.turbo_stream do
107
+ render "verify_2fa_error"
108
+ end
109
+ end
110
+ end
111
+ end
14
112
 
15
113
  # DELETE /easy_admin/sign_out
16
114
  def destroy
115
+ # Clear any pending 2FA session on sign out
116
+ session.delete(:pending_2fa_user_id)
17
117
  super
18
118
  end
19
119
 
@@ -28,5 +128,11 @@ module EasyAdmin
28
128
  def after_sign_out_path_for(resource_or_scope)
29
129
  new_admin_user_session_path
30
130
  end
131
+
132
+ private
133
+
134
+ def auth_options
135
+ { scope: resource_name, recall: "#{controller_path}#new" }
136
+ end
31
137
  end
32
- end
138
+ end
@@ -17,43 +17,26 @@ module EasyAdmin
17
17
  return ""
18
18
  when :custom_content
19
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
20
  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)
21
+
28
22
  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
23
  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
24
  render(result)
39
25
  elsif result.is_a?(String)
40
- Rails.logger.debug "🔍 [FieldsHelper] Rendering string result"
41
26
  result.html_safe
42
27
  elsif result.respond_to?(:to_s)
43
- Rails.logger.debug "🔍 [FieldsHelper] Converting to string and rendering"
44
28
  result.to_s.html_safe
45
29
  else
46
- Rails.logger.debug "🔍 [FieldsHelper] No valid result, returning empty string"
47
30
  ""
48
31
  end
49
32
  else
50
- Rails.logger.debug "🔍 [FieldsHelper] No block provided for custom_content"
51
33
  ""
52
34
  end
53
35
  else
54
36
  # Regular field rendering
37
+ field_type = field[:type] || field[:field_type] || :text
55
38
  component = EasyAdmin::Field.render(
56
- field[:type],
39
+ field_type,
57
40
  action: action,
58
41
  field: field,
59
42
  value: value,
@@ -61,13 +44,16 @@ module EasyAdmin
61
44
  form: form
62
45
  )
63
46
 
64
- component.call.html_safe
47
+ result = component.call
48
+
49
+ result.html_safe
65
50
  end
66
51
  end
67
52
 
68
53
  def field_component(field, action:, value: nil, record: nil, form: nil)
54
+ field_type = field[:type] || field[:field_type] || :text
69
55
  EasyAdmin::Field.render(
70
- field[:type],
56
+ field_type,
71
57
  action: action,
72
58
  field: field,
73
59
  value: value,
@@ -19,14 +19,26 @@ export default class extends Controller {
19
19
  // Called when the element is replaced by turbo stream
20
20
  urlValueChanged() {
21
21
  if (this.observer && this.sentinel) {
22
+ // Reset loading state when URL changes (e.g., when filters are applied)
23
+ this.isLoading = false
24
+ this.hideLoading()
25
+
22
26
  // Re-setup observer with new URL
23
27
  this.setupIntersectionObserver()
28
+
29
+ // Re-initialize UI state
30
+ this.initializeUI()
24
31
  }
25
32
  }
26
33
 
27
34
  hasMoreValueChanged() {
28
35
  if (!this.hasMoreValue) {
29
36
  this.showEndMessage()
37
+ } else {
38
+ // Hide end message if we have more pages (e.g., after filters change)
39
+ if (this.hasEndTarget) {
40
+ this.endTarget.style.display = 'none'
41
+ }
30
42
  }
31
43
  }
32
44
 
@@ -0,0 +1,112 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tab", "content"]
5
+
6
+ connect() {
7
+ // Check for tab parameter in URL query string
8
+ const urlParams = new URLSearchParams(window.location.search)
9
+ const tabFromUrl = urlParams.get('tab')
10
+
11
+ // If tab parameter exists and matches a valid tab, use it
12
+ if (tabFromUrl && this.isValidTab(tabFromUrl)) {
13
+ this.activateTab(tabFromUrl)
14
+ } else {
15
+ // Set initial active tab (first tab by default)
16
+ const firstTab = this.tabTargets[0]
17
+ if (firstTab) {
18
+ this.activateTab(firstTab.dataset.tabId)
19
+ }
20
+ }
21
+
22
+ // Listen for popstate events (back/forward navigation)
23
+ this.handlePopState = this.handlePopState.bind(this)
24
+ window.addEventListener('popstate', this.handlePopState)
25
+ }
26
+
27
+ disconnect() {
28
+ window.removeEventListener('popstate', this.handlePopState)
29
+ }
30
+
31
+ switchTab(event) {
32
+ event.preventDefault()
33
+ const tabId = event.currentTarget.dataset.tabId
34
+ this.activateTab(tabId)
35
+ }
36
+
37
+ activateTab(tabId) {
38
+ // Update tab buttons
39
+ this.tabTargets.forEach(tab => {
40
+ const isActive = tab.dataset.tabId === tabId
41
+
42
+ if (isActive) {
43
+ tab.classList.remove("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
44
+ tab.classList.add("bg-blue-50", "text-blue-700", "border-blue-200")
45
+ } else {
46
+ tab.classList.remove("bg-blue-50", "text-blue-700", "border-blue-200")
47
+ tab.classList.add("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
48
+ }
49
+ })
50
+
51
+ // Update content areas
52
+ this.contentTargets.forEach(content => {
53
+ const isActive = content.dataset.tabId === tabId
54
+
55
+ if (isActive) {
56
+ content.classList.remove("hidden")
57
+ } else {
58
+ content.classList.add("hidden")
59
+ }
60
+ })
61
+
62
+ // Update URL hash for deep linking (optional)
63
+ if (history.replaceState) {
64
+ const url = new URL(window.location)
65
+ url.searchParams.set('tab', tabId)
66
+ history.replaceState(null, '', url)
67
+ }
68
+ }
69
+
70
+ isValidTab(tabId) {
71
+ return this.tabTargets.some(tab => tab.dataset.tabId === tabId)
72
+ }
73
+
74
+ handlePopState() {
75
+ const urlParams = new URLSearchParams(window.location.search)
76
+ const tabFromUrl = urlParams.get('tab')
77
+
78
+ if (tabFromUrl && this.isValidTab(tabFromUrl)) {
79
+ this.activateTabSilently(tabFromUrl)
80
+ } else {
81
+ const firstTab = this.tabTargets[0]
82
+ if (firstTab) {
83
+ this.activateTabSilently(firstTab.dataset.tabId)
84
+ }
85
+ }
86
+ }
87
+
88
+ activateTabSilently(tabId) {
89
+ // Activate tab without updating URL (for popstate handling)
90
+ this.tabTargets.forEach(tab => {
91
+ const isActive = tab.dataset.tabId === tabId
92
+
93
+ if (isActive) {
94
+ tab.classList.remove("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
95
+ tab.classList.add("bg-blue-50", "text-blue-700", "border-blue-200")
96
+ } else {
97
+ tab.classList.remove("bg-blue-50", "text-blue-700", "border-blue-200")
98
+ tab.classList.add("text-gray-700", "hover:bg-gray-100", "hover:text-gray-900")
99
+ }
100
+ })
101
+
102
+ this.contentTargets.forEach(content => {
103
+ const isActive = content.dataset.tabId === tabId
104
+
105
+ if (isActive) {
106
+ content.classList.remove("hidden")
107
+ } else {
108
+ content.classList.add("hidden")
109
+ }
110
+ })
111
+ }
112
+ }
@@ -28,6 +28,7 @@ import JsoneditorController from './controllers/jsoneditor_controller'
28
28
  import VersionRevertController from './controllers/version_revert_controller'
29
29
  import RolePreviewController from './controllers/role_preview_controller'
30
30
  import PermissionToggleController from './controllers/permission_toggle_controller'
31
+ import VerticalTabsController from './controllers/vertical_tabs_controller'
31
32
 
32
33
  // Register controllers
33
34
  application.register('sidebar', SidebarController)
@@ -57,4 +58,5 @@ application.register('row-action', RowActionController)
57
58
  application.register('jsoneditor', JsoneditorController)
58
59
  application.register('version-revert', VersionRevertController)
59
60
  application.register('role-preview', RolePreviewController)
60
- application.register('permission-toggle', PermissionToggleController)
61
+ application.register('permission-toggle', PermissionToggleController)
62
+ application.register('vertical-tabs', VerticalTabsController)
@@ -10,6 +10,9 @@ module EasyAdmin
10
10
  # EasyAdmin Permissions
11
11
  include EasyAdmin::Permissions::UserExtensions
12
12
 
13
+ # Two-Factor Authentication (optional)
14
+ include EasyAdmin::TwoFactorAuthentication
15
+
13
16
  # Direct role association (each admin user has one role)
14
17
  belongs_to :role, class_name: 'EasyAdmin::Permissions::Role', optional: true
15
18
 
@@ -0,0 +1,12 @@
1
+ <%= turbo_stream.replace "backup_codes_section" do %>
2
+ <div id="backup_codes_section">
3
+ <%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
4
+ </div>
5
+ <% end %>
6
+
7
+ <%= turbo_stream.replace "notifications" do %>
8
+ <%== EasyAdmin::NotificationComponent.new(
9
+ message: "New backup codes have been generated",
10
+ type: :success
11
+ ).call %>
12
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <% if turbo_frame_request? %>
2
+ <turbo-frame id="modal">
3
+ <div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
4
+ <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
5
+ <div class="mt-3">
6
+ <!-- Modal header -->
7
+ <div class="flex items-center justify-between mb-4">
8
+ <h3 class="text-lg font-medium text-gray-900">Change Password</h3>
9
+ <button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
10
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
12
+ </svg>
13
+ </button>
14
+ </div>
15
+
16
+ <!-- Modal content -->
17
+ <%== EasyAdmin::Profile::ChangePasswordModalComponent.new(user: current_admin_user).call %>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </turbo-frame>
22
+ <% else %>
23
+ <%== EasyAdmin::Profile::ChangePasswordModalComponent.new(user: current_admin_user).call %>
24
+ <% end %>
@@ -0,0 +1 @@
1
+ <%== EasyAdmin::Profile::SettingsComponent.new(user: @user).call %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Failed to update password. Please check your input and try again.",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Current password is incorrect. Please try again.",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
@@ -0,0 +1,9 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Password updated successfully!",
4
+ type: :success
5
+ ).call %>
6
+ <% end %>
7
+
8
+ <%= turbo_stream.update "modal" do %>
9
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <% if turbo_frame_request? %>
2
+ <turbo-frame id="modal">
3
+ <div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
4
+ <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
5
+ <div class="mt-3">
6
+ <!-- Modal header -->
7
+ <div class="flex items-center justify-between mb-4">
8
+ <h3 class="text-lg font-medium text-gray-900">Backup Codes</h3>
9
+ <button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
10
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
12
+ </svg>
13
+ </button>
14
+ </div>
15
+
16
+ <!-- Modal content -->
17
+ <%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </turbo-frame>
22
+ <% else %>
23
+ <%== EasyAdmin::TwoFactor::BackupCodesComponent.new(user: current_admin_user).call %>
24
+ <% end %>
@@ -0,0 +1,12 @@
1
+ <%= turbo_stream.replace "two_factor_section" do %>
2
+ <div id="two_factor_section">
3
+ <%== EasyAdmin::TwoFactor::StatusComponent.new(user: current_admin_user).call %>
4
+ </div>
5
+ <% end %>
6
+
7
+ <%= turbo_stream.replace "notifications" do %>
8
+ <%== EasyAdmin::NotificationComponent.new(
9
+ message: "Two-factor authentication has been enabled successfully!",
10
+ type: :success
11
+ ).call %>
12
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Invalid verification code. Please try again.",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Two-factor authentication is not enabled",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <% if turbo_frame_request? %>
2
+ <turbo-frame id="modal">
3
+ <div id="modal-backdrop" class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 opacity-100 transition-opacity duration-300" data-controller="modal" data-action="click->modal#closeOnBackdrop">
4
+ <div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 xl:w-2/5 shadow-lg rounded-md bg-white">
5
+ <div class="mt-3">
6
+ <!-- Modal header -->
7
+ <div class="flex items-center justify-between mb-4">
8
+ <h3 class="text-lg font-medium text-gray-900">Set up Two-Factor Authentication</h3>
9
+ <button class="text-gray-400 hover:text-gray-600 focus:outline-none" data-action="click->modal#close">
10
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
11
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
12
+ </svg>
13
+ </button>
14
+ </div>
15
+
16
+ <!-- Modal content -->
17
+ <%== EasyAdmin::TwoFactor::SetupComponent.new(user: current_admin_user).call %>
18
+ </div>
19
+ </div>
20
+ </div>
21
+ </turbo-frame>
22
+ <% else %>
23
+ <%== EasyAdmin::TwoFactor::SetupComponent.new(user: current_admin_user).call %>
24
+ <% end %>
@@ -0,0 +1,6 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "2FA is not available. Contact system administrator.",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
@@ -116,8 +116,8 @@
116
116
  <% end %>
117
117
 
118
118
  <!-- Form Fields -->
119
- <% if @resource_class.has_form_tabs? %>
120
- <%= EasyAdmin::FormTabsComponent.new(resource_class: @resource_class, form: form, record: @record).call.html_safe %>
119
+ <% if @resource_class.has_custom_form_layout? %>
120
+ <%== EasyAdmin::FormLayoutComponent.new(resource_class: @resource_class, form: form, record: @record).call %>
121
121
  <% else %>
122
122
  <!-- Default Single Card Layout -->
123
123
  <div class="bg-white shadow-sm rounded-lg border border-gray-200">
@@ -65,8 +65,8 @@
65
65
  <% end %>
66
66
 
67
67
  <!-- Form Fields -->
68
- <% if @resource_class.has_form_tabs? %>
69
- <%= EasyAdmin::FormTabsComponent.new(resource_class: @resource_class, form: form).call.html_safe %>
68
+ <% if @resource_class.has_custom_form_layout? %>
69
+ <%== EasyAdmin::FormLayoutComponent.new(resource_class: @resource_class, form: form).call %>
70
70
  <% else %>
71
71
  <!-- Default Single Card Layout -->
72
72
  <div class="bg-white shadow-sm rounded-lg border border-gray-200">
@@ -27,5 +27,7 @@
27
27
 
28
28
  <!-- Show Layout Content -->
29
29
  <div class="show-content">
30
- <%== EasyAdmin::ShowLayoutComponent.new(resource_class: @resource_class, record: @record).call %>
30
+ <div class="container-fluid">
31
+ <%== EasyAdmin::ShowLayoutComponent.new(resource_class: @resource_class, record: @record).call %>
32
+ </div>
31
33
  </div>
@@ -0,0 +1,48 @@
1
+ <!-- Form -->
2
+ <div class="p-10">
3
+ <%= form_with url: verify_2fa_path, method: :post, local: true, html: { class: "space-y-8" } do |f| %>
4
+
5
+ <!-- Header -->
6
+ <div class="text-center mb-6">
7
+ <h2 class="text-xl font-bold text-gray-900 mb-2">Two-Factor Authentication</h2>
8
+ <p class="text-gray-600">Enter the 6-digit code from your authenticator app</p>
9
+ <% if @user %>
10
+ <p class="text-sm text-gray-500 mt-2">Signing in as <%= @user.email %></p>
11
+ <% end %>
12
+ </div>
13
+
14
+ <!-- Input Fields Container -->
15
+ <div class="space-y-4">
16
+ <!-- OTP Code Field -->
17
+ <div class="relative">
18
+ <%= text_field_tag :otp_code, '', autofocus: true, autocomplete: "one-time-code",
19
+ maxlength: 6, pattern: "[0-9]{6}", id: "otp_code",
20
+ class: "w-full px-6 py-5 bg-gray-50/80 border-0 text-gray-900 placeholder-gray-400 focus:bg-gray-100/80 focus:ring-0 focus:outline-none text-base rounded-2xl transition-colors duration-200 font-medium text-center tracking-widest",
21
+ placeholder: "000000" %>
22
+ </div>
23
+ </div>
24
+
25
+ <!-- Submit Button -->
26
+ <div class="pt-4">
27
+ <%= f.submit "Verify Code",
28
+ class: "w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white font-bold py-5 px-6 rounded-2xl transition-all duration-200 focus:outline-none focus:ring-4 focus:ring-blue-500/30 text-base shadow-xl shadow-blue-500/20 active:scale-[0.98]" %>
29
+ </div>
30
+
31
+ <% end %>
32
+
33
+ <!-- Help Text -->
34
+ <div class="text-center pt-6">
35
+ <p class="text-sm text-gray-500 mb-2">
36
+ Can't access your authenticator app?
37
+ </p>
38
+ <p class="text-sm text-gray-400">
39
+ Contact your administrator for help with account recovery.
40
+ </p>
41
+ </div>
42
+
43
+ <!-- Back to Sign In -->
44
+ <div class="text-center pt-4">
45
+ <%= link_to "← Back to Sign In", cancel_2fa_path,
46
+ class: "text-sm text-blue-500 hover:text-blue-600 font-medium transition-colors duration-200" %>
47
+ </div>
48
+ </div>
@@ -0,0 +1,13 @@
1
+ <%= turbo_stream.replace "notifications" do %>
2
+ <%== EasyAdmin::NotificationComponent.new(
3
+ message: "Invalid authentication code. Please try again.",
4
+ type: :error
5
+ ).call %>
6
+ <% end %>
7
+
8
+ <%= turbo_stream.replace "otp_code" do %>
9
+ <%= text_field_tag :otp_code, '', autofocus: true, autocomplete: "one-time-code",
10
+ maxlength: 6, pattern: "[0-9]{6}",
11
+ class: "w-full px-6 py-5 bg-gray-50/80 border-0 text-gray-900 placeholder-gray-400 focus:bg-gray-100/80 focus:ring-0 focus:outline-none text-base rounded-2xl transition-colors duration-200 font-medium text-center tracking-widest",
12
+ placeholder: "000000" %>
13
+ <% end %>
data/config/routes.rb CHANGED
@@ -13,13 +13,32 @@ EasyAdmin::Engine.routes.draw do
13
13
  sign_out: 'sign_out',
14
14
  sign_up: 'sign_up'
15
15
  }
16
+
17
+ # 2FA verification routes (wrapped in devise_scope)
18
+ devise_scope :admin_user do
19
+ get 'two_factor_verification', to: 'sessions#two_factor_verification', as: 'two_factor_verification'
20
+ post 'verify_2fa', to: 'sessions#verify_2fa', as: 'verify_2fa'
21
+ get 'cancel_2fa', to: 'sessions#cancel_2fa', as: 'cancel_2fa'
22
+ end
16
23
 
17
24
  root 'dashboard#index'
18
25
 
19
- # Settings routes
26
+ # Global settings routes (Flipper)
20
27
  get 'settings', to: 'settings#index'
21
28
  patch 'settings', to: 'settings#update'
22
29
 
30
+ # User profile settings routes
31
+ get 'profile', to: 'profile#index', as: 'profile'
32
+ patch 'profile', to: 'profile#update', as: 'update_profile'
33
+ get 'profile/change_password', to: 'profile#change_password', as: 'change_password'
34
+ patch 'profile/change_password', to: 'profile#update_password', as: 'update_password'
35
+
36
+ # Two-factor authentication routes (via profile controller)
37
+ get 'profile/two_factor/setup', to: 'profile#two_factor_setup', as: 'two_factor_setup'
38
+ post 'profile/two_factor/enable', to: 'profile#two_factor_enable', as: 'two_factor_enable'
39
+ get 'profile/two_factor/backup_codes', to: 'profile#two_factor_backup_codes', as: 'two_factor_backup_codes'
40
+ post 'profile/two_factor/regenerate_backup_codes', to: 'profile#regenerate_backup_codes', as: 'two_factor_regenerate_backup_codes'
41
+
23
42
  # Confirmation modal
24
43
  get 'confirmation_modal', to: 'confirmation_modal#show'
25
44
 
@@ -14,6 +14,7 @@ require "easy_admin/action"
14
14
  require "easy_admin/delete_action"
15
15
  require "easy_admin/batch_action"
16
16
  require "easy_admin/permissions"
17
+ require "easy_admin/two_factor_authentication"
17
18
  require "easy_admin/engine"
18
19
 
19
20
  module EasyAdmin