easy-admin-rails 0.2.4 → 0.2.6

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +100 -27
  3. data/app/assets/builds/easy_admin.base.js.map +4 -4
  4. data/app/assets/builds/easy_admin.css +59 -0
  5. data/app/components/easy_admin/navbar_component.rb +19 -4
  6. data/app/components/easy_admin/permissions/user_role_assignment_component.rb +43 -16
  7. data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
  8. data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
  9. data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
  10. data/app/components/easy_admin/profile/settings_component.rb +103 -0
  11. data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
  12. data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
  13. data/app/components/easy_admin/two_factor/status_component.rb +92 -0
  14. data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
  15. data/app/controllers/easy_admin/application_controller.rb +10 -0
  16. data/app/controllers/easy_admin/profile_controller.rb +25 -0
  17. data/app/controllers/easy_admin/sessions_controller.rb +107 -1
  18. data/app/javascript/easy_admin/controllers/collapsible_filters_controller.js +14 -7
  19. data/app/javascript/easy_admin/controllers/permission_toggle_controller.js +2 -33
  20. data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
  21. data/app/javascript/easy_admin/controllers.js +3 -1
  22. data/app/models/easy_admin/admin_user.rb +3 -0
  23. data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
  24. data/app/views/easy_admin/profile/change_password.html.erb +24 -0
  25. data/app/views/easy_admin/profile/index.html.erb +1 -0
  26. data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
  27. data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
  28. data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
  29. data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
  30. data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
  31. data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
  32. data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
  33. data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
  34. data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
  35. data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
  36. data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
  37. data/config/routes.rb +20 -1
  38. data/lib/easy-admin-rails.rb +1 -0
  39. data/lib/easy_admin/two_factor_authentication.rb +156 -0
  40. data/lib/easy_admin/version.rb +1 -1
  41. data/lib/generators/easy_admin/two_factor/templates/README +29 -0
  42. data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
  43. data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
  44. metadata +30 -2
@@ -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 %>
@@ -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
@@ -0,0 +1,156 @@
1
+ module EasyAdmin
2
+ module TwoFactorAuthentication
3
+ extend ActiveSupport::Concern
4
+
5
+ # Check if required gems are available
6
+ def self.available?
7
+ @available ||= begin
8
+ require 'rotp'
9
+ require 'rqrcode'
10
+ true
11
+ rescue LoadError
12
+ false
13
+ end
14
+ end
15
+
16
+ included do
17
+ # Only add validations and callbacks if 2FA gems are available
18
+ if EasyAdmin::TwoFactorAuthentication.available?
19
+ validates :otp_secret, presence: true, if: :otp_required_for_login?
20
+ validates :otp_secret, uniqueness: true, allow_blank: true
21
+ end
22
+ end
23
+
24
+ def two_factor_available?
25
+ EasyAdmin::TwoFactorAuthentication.available?
26
+ end
27
+
28
+ def two_factor_enabled?
29
+ two_factor_available? && otp_required_for_login? && otp_secret.present?
30
+ end
31
+
32
+ def generate_otp_secret!
33
+ return false unless two_factor_available?
34
+
35
+ require 'rotp'
36
+ self.otp_secret = ROTP::Base32.random
37
+ save!
38
+ end
39
+
40
+ def current_otp
41
+ return nil unless two_factor_available? && otp_secret.present?
42
+
43
+ require 'rotp'
44
+ ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin").now
45
+ end
46
+
47
+ def validate_and_consume_otp!(token)
48
+ return false unless two_factor_available? && otp_secret.present?
49
+ return false if token.blank?
50
+
51
+ require 'rotp'
52
+
53
+ totp = ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin")
54
+ last_otp_at_timestamp = last_otp_at&.to_i
55
+
56
+ # Verify with 30-second drift tolerance and replay protection
57
+ if totp.verify(token.to_s, drift_behind: 30, drift_ahead: 30, after: last_otp_at_timestamp)
58
+ touch(:last_otp_at)
59
+ true
60
+ else
61
+ false
62
+ end
63
+ end
64
+
65
+ def validate_backup_code!(code)
66
+ return false unless two_factor_available?
67
+ return false if code.blank? || otp_backup_codes.blank?
68
+
69
+ normalized_code = code.to_s.upcase.strip
70
+
71
+ if otp_backup_codes.include?(normalized_code)
72
+ invalidate_backup_code!(normalized_code)
73
+ true
74
+ else
75
+ false
76
+ end
77
+ end
78
+
79
+ def generate_backup_codes!
80
+ return false unless two_factor_available?
81
+
82
+ # Generate 10 backup codes (8 characters each)
83
+ codes = 10.times.map { SecureRandom.hex(4).upcase }
84
+ self.otp_backup_codes = codes
85
+ save!
86
+ codes
87
+ end
88
+
89
+ def invalidate_backup_code!(code)
90
+ return false unless two_factor_available?
91
+
92
+ normalized_code = code.to_s.upcase.strip
93
+ self.otp_backup_codes = otp_backup_codes.reject { |c| c == normalized_code }
94
+ save!
95
+ end
96
+
97
+ def backup_codes_remaining
98
+ two_factor_available? ? (otp_backup_codes&.length || 0) : 0
99
+ end
100
+
101
+ def provisioning_uri
102
+ return nil unless two_factor_available? && otp_secret.present?
103
+
104
+ require 'rotp'
105
+ ROTP::TOTP.new(otp_secret, issuer: "EasyAdmin").provisioning_uri(email)
106
+ end
107
+
108
+ def qr_code_svg(size: 200)
109
+ return nil unless two_factor_available?
110
+
111
+ uri = provisioning_uri
112
+ return nil if uri.blank?
113
+
114
+ require 'rqrcode'
115
+
116
+ qr_code = RQRCode::QRCode.new(uri)
117
+ qr_code.as_svg(
118
+ viewbox: true,
119
+ module_size: 4,
120
+ standalone: true,
121
+ use_path: true
122
+ )
123
+ end
124
+
125
+ def enable_two_factor!
126
+ return false unless two_factor_available? && otp_secret.present?
127
+
128
+ update!(otp_required_for_login: true)
129
+ end
130
+
131
+ def disable_two_factor!
132
+ update!(
133
+ otp_required_for_login: false,
134
+ otp_secret: nil,
135
+ otp_backup_codes: nil,
136
+ last_otp_at: nil
137
+ )
138
+ end
139
+
140
+ # Check if user needs 2FA based on role requirements
141
+ def two_factor_required?
142
+ return false unless two_factor_available?
143
+
144
+ # Check if role requires 2FA (if role system exists)
145
+ if respond_to?(:role) && role.respond_to?(:require_two_factor?)
146
+ role.require_two_factor?
147
+ else
148
+ false
149
+ end
150
+ end
151
+
152
+ def should_enable_two_factor?
153
+ two_factor_required? && !two_factor_enabled?
154
+ end
155
+ end
156
+ end
@@ -1,3 +1,3 @@
1
1
  module EasyAdmin
2
- VERSION = "0.2.4"
2
+ VERSION = "0.2.6"
3
3
  end
@@ -0,0 +1,29 @@
1
+ ===============================================================================
2
+
3
+ Two-Factor Authentication has been added to EasyAdmin!
4
+
5
+ To complete the setup, you'll need to:
6
+
7
+ 1. Install the required gems for 2FA functionality:
8
+
9
+ gem 'rotp', '~> 6.3' # TOTP generation/validation
10
+ gem 'rqrcode', '~> 2.2' # QR code generation
11
+
12
+ 2. Run the migration:
13
+
14
+ rails db:migrate
15
+
16
+ 3. Restart your server
17
+
18
+ 4. Two-factor authentication is now available in user settings!
19
+
20
+ Features:
21
+ • Optional 2FA (disabled by default)
22
+ • TOTP-based (compatible with Google Authenticator, Authy, etc.)
23
+ • QR code setup for easy configuration
24
+ • Backup codes for account recovery
25
+ • Admin can view/manage 2FA status for users
26
+
27
+ Without the optional gems, 2FA features will be gracefully hidden.
28
+
29
+ ===============================================================================
@@ -0,0 +1,10 @@
1
+ class AddTwoFactorToEasyAdminAdminUsers < ActiveRecord::Migration[<%= ActiveRecord::VERSION::MAJOR %>.<%= ActiveRecord::VERSION::MINOR %>]
2
+ def change
3
+ add_column :easy_admin_admin_users, :otp_secret, :string
4
+ add_column :easy_admin_admin_users, :otp_required_for_login, :boolean, default: false, null: false
5
+ add_column :easy_admin_admin_users, :otp_backup_codes, :json
6
+ add_column :easy_admin_admin_users, :last_otp_at, :datetime
7
+
8
+ add_index :easy_admin_admin_users, :otp_secret, unique: true
9
+ end
10
+ end
@@ -0,0 +1,22 @@
1
+ require 'rails/generators'
2
+
3
+ module EasyAdmin
4
+ module Generators
5
+ class TwoFactorGenerator < Rails::Generators::Base
6
+ source_root File.expand_path('templates', __dir__)
7
+
8
+ desc "Add two-factor authentication fields to EasyAdmin AdminUser model"
9
+
10
+ def create_migration
11
+ timestamp = Time.current.utc.strftime("%Y%m%d%H%M%S")
12
+ migration_file = "#{timestamp}_add_two_factor_to_easy_admin_admin_users.rb"
13
+
14
+ template 'migration.rb', "db/migrate/#{migration_file}"
15
+ end
16
+
17
+ def show_readme
18
+ readme "README" if behavior == :invoke
19
+ end
20
+ end
21
+ end
22
+ end