easy-admin-rails 0.2.5 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/easy_admin.base.js +88 -0
  3. data/app/assets/builds/easy_admin.base.js.map +3 -3
  4. data/app/assets/builds/easy_admin.css +54 -0
  5. data/app/components/easy_admin/navbar_component.rb +19 -4
  6. data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
  7. data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
  8. data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
  9. data/app/components/easy_admin/profile/settings_component.rb +103 -0
  10. data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
  11. data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
  12. data/app/components/easy_admin/two_factor/status_component.rb +92 -0
  13. data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
  14. data/app/controllers/easy_admin/application_controller.rb +10 -0
  15. data/app/controllers/easy_admin/profile_controller.rb +25 -0
  16. data/app/controllers/easy_admin/sessions_controller.rb +107 -1
  17. data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
  18. data/app/javascript/easy_admin/controllers.js +3 -1
  19. data/app/models/easy_admin/admin_user.rb +3 -0
  20. data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
  21. data/app/views/easy_admin/profile/change_password.html.erb +24 -0
  22. data/app/views/easy_admin/profile/index.html.erb +1 -0
  23. data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
  24. data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
  25. data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
  26. data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
  27. data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
  28. data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
  29. data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
  30. data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
  31. data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
  32. data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
  33. data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
  34. data/config/routes.rb +20 -1
  35. data/lib/easy-admin-rails.rb +1 -0
  36. data/lib/easy_admin/two_factor_authentication.rb +156 -0
  37. data/lib/easy_admin/version.rb +1 -1
  38. data/lib/generators/easy_admin/two_factor/templates/README +29 -0
  39. data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
  40. data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
  41. metadata +30 -2
@@ -3644,6 +3644,10 @@ input:checked + .toggle-switch .toggle-slider:before {
3644
3644
  margin-top: auto;
3645
3645
  }
3646
3646
 
3647
+ .mb-8 {
3648
+ margin-bottom: 2rem;
3649
+ }
3650
+
3647
3651
  .block {
3648
3652
  display: block;
3649
3653
  }
@@ -3748,6 +3752,10 @@ input:checked + .toggle-switch .toggle-slider:before {
3748
3752
  height: 100%;
3749
3753
  }
3750
3754
 
3755
+ .h-48 {
3756
+ height: 12rem;
3757
+ }
3758
+
3751
3759
  .max-h-60 {
3752
3760
  max-height: 15rem;
3753
3761
  }
@@ -3929,6 +3937,10 @@ input:checked + .toggle-switch .toggle-slider:before {
3929
3937
  max-width: 24rem;
3930
3938
  }
3931
3939
 
3940
+ .max-w-6xl {
3941
+ max-width: 72rem;
3942
+ }
3943
+
3932
3944
  .flex-1 {
3933
3945
  flex: 1 1 0%;
3934
3946
  }
@@ -4257,6 +4269,10 @@ input:checked + .toggle-switch .toggle-slider:before {
4257
4269
  white-space: pre-wrap;
4258
4270
  }
4259
4271
 
4272
+ .break-all {
4273
+ word-break: break-all;
4274
+ }
4275
+
4260
4276
  .rounded {
4261
4277
  border-radius: 0.25rem;
4262
4278
  }
@@ -5005,6 +5021,10 @@ input:checked + .toggle-switch .toggle-slider:before {
5005
5021
  padding-top: 1.5rem;
5006
5022
  }
5007
5023
 
5024
+ .pt-8 {
5025
+ padding-top: 2rem;
5026
+ }
5027
+
5008
5028
  .text-left {
5009
5029
  text-align: left;
5010
5030
  }
@@ -5113,6 +5133,10 @@ input:checked + .toggle-switch .toggle-slider:before {
5113
5133
  letter-spacing: 0.05em;
5114
5134
  }
5115
5135
 
5136
+ .tracking-widest {
5137
+ letter-spacing: 0.1em;
5138
+ }
5139
+
5116
5140
  .text-amber-400 {
5117
5141
  --tw-text-opacity: 1;
5118
5142
  color: rgb(251 191 36 / var(--tw-text-opacity, 1));
@@ -5277,6 +5301,21 @@ input:checked + .toggle-switch .toggle-slider:before {
5277
5301
  color: rgb(133 77 14 / var(--tw-text-opacity, 1));
5278
5302
  }
5279
5303
 
5304
+ .text-yellow-500 {
5305
+ --tw-text-opacity: 1;
5306
+ color: rgb(234 179 8 / var(--tw-text-opacity, 1));
5307
+ }
5308
+
5309
+ .text-yellow-900 {
5310
+ --tw-text-opacity: 1;
5311
+ color: rgb(113 63 18 / var(--tw-text-opacity, 1));
5312
+ }
5313
+
5314
+ .text-red-900 {
5315
+ --tw-text-opacity: 1;
5316
+ color: rgb(127 29 29 / var(--tw-text-opacity, 1));
5317
+ }
5318
+
5280
5319
  .underline {
5281
5320
  text-decoration-line: underline;
5282
5321
  }
@@ -5836,6 +5875,21 @@ input:checked + .toggle-switch .toggle-slider:before {
5836
5875
  background-color: rgb(234 179 8 / var(--tw-bg-opacity, 1));
5837
5876
  }
5838
5877
 
5878
+ .hover\:bg-green-700:hover {
5879
+ --tw-bg-opacity: 1;
5880
+ background-color: rgb(21 128 61 / var(--tw-bg-opacity, 1));
5881
+ }
5882
+
5883
+ .hover\:bg-green-200:hover {
5884
+ --tw-bg-opacity: 1;
5885
+ background-color: rgb(187 247 208 / var(--tw-bg-opacity, 1));
5886
+ }
5887
+
5888
+ .hover\:bg-red-200:hover {
5889
+ --tw-bg-opacity: 1;
5890
+ background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
5891
+ }
5892
+
5839
5893
  .hover\:from-blue-600:hover {
5840
5894
  --tw-gradient-from: #2563eb var(--tw-gradient-from-position);
5841
5895
  --tw-gradient-to: rgb(37 99 235 / 0) var(--tw-gradient-to-position);
@@ -160,17 +160,28 @@ module EasyAdmin
160
160
  data: { dropdown_target: "menu" }
161
161
  ) do
162
162
  div(class: "py-1") do
163
- render_menu_item("Sign out", logout_icon, "#", "text-red-600 hover:bg-red-50")
163
+ render_menu_item("Settings", settings_menu_icon, EasyAdmin::Engine.routes.url_helpers.profile_path, "text-gray-700 hover:bg-gray-100")
164
+ render_menu_item("Sign out", logout_icon, EasyAdmin::Engine.routes.url_helpers.destroy_admin_user_session_path, "text-red-600 hover:bg-red-50", method: :delete)
164
165
  end
165
166
  end
166
167
  end
167
168
  end
168
169
 
169
- def render_menu_item(label, icon_svg, url, extra_classes = "text-gray-700 hover:bg-gray-100")
170
- a(
170
+ def render_menu_item(label, icon_svg, url, extra_classes = "text-gray-700 hover:bg-gray-100", method: :get)
171
+ link_options = {
171
172
  href: url,
172
173
  class: "block px-4 py-2 text-sm #{extra_classes}"
173
- ) do
174
+ }
175
+
176
+ # Add method and confirmation for delete operations
177
+ if method == :delete
178
+ link_options[:data] = {
179
+ turbo_method: :delete,
180
+ turbo_confirm: "Are you sure you want to sign out?"
181
+ }
182
+ end
183
+
184
+ a(**link_options) do
174
185
  div(class: "flex items-center space-x-2") do
175
186
  unsafe_raw(icon_svg)
176
187
  span { label }
@@ -219,6 +230,10 @@ module EasyAdmin
219
230
  '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'
220
231
  end
221
232
 
233
+ def settings_menu_icon
234
+ '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>'
235
+ end
236
+
222
237
  def logout_icon
223
238
  '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>'
224
239
  end
@@ -0,0 +1,75 @@
1
+ module EasyAdmin
2
+ module Profile
3
+ class ChangePasswordModalComponent < BaseComponent
4
+ def initialize(user:)
5
+ @user = user
6
+ end
7
+
8
+ def view_template
9
+ div do
10
+ p(class: "text-sm text-gray-600 mb-6") { "Update your password to keep your account secure" }
11
+
12
+ form(action: EasyAdmin::Engine.routes.url_helpers.update_password_path, method: :patch, data: { turbo_frame: "_top" }) do
13
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token) if helpers.respond_to?(:form_authenticity_token)
14
+ input(type: "hidden", name: "_method", value: "patch")
15
+
16
+ div(class: "space-y-4") do
17
+ # Current Password
18
+ div do
19
+ label(for: "admin_user_current_password", class: "block text-sm font-medium text-gray-700 mb-2") { "Current Password" }
20
+ input(
21
+ type: "password",
22
+ name: "admin_user[current_password]",
23
+ id: "admin_user_current_password",
24
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
25
+ placeholder: "Enter current password",
26
+ required: true
27
+ )
28
+ end
29
+
30
+ # New Password
31
+ div do
32
+ label(for: "admin_user_password", class: "block text-sm font-medium text-gray-700 mb-2") { "New Password" }
33
+ input(
34
+ type: "password",
35
+ name: "admin_user[password]",
36
+ id: "admin_user_password",
37
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
38
+ placeholder: "Enter new password",
39
+ required: true
40
+ )
41
+ end
42
+
43
+ # Confirm Password
44
+ div do
45
+ label(for: "admin_user_password_confirmation", class: "block text-sm font-medium text-gray-700 mb-2") { "Confirm New Password" }
46
+ input(
47
+ type: "password",
48
+ name: "admin_user[password_confirmation]",
49
+ id: "admin_user_password_confirmation",
50
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
51
+ placeholder: "Confirm new password",
52
+ required: true
53
+ )
54
+ end
55
+
56
+ # Submit button
57
+ div(class: "flex space-x-3 pt-4") do
58
+ input(
59
+ type: "submit",
60
+ value: "Update Password",
61
+ class: "flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer"
62
+ )
63
+ button(
64
+ type: "button",
65
+ class: "flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-400 transition-colors",
66
+ data: { action: "click->modal#close" }
67
+ ) { "Cancel" }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,92 @@
1
+ module EasyAdmin
2
+ module Profile
3
+ class ProfileTabComponent < BaseComponent
4
+ def initialize(user:)
5
+ @user = user
6
+ end
7
+
8
+ def view_template
9
+ div do
10
+ # Section header
11
+ div(class: "mb-6") do
12
+ h2(class: "text-lg font-medium text-gray-900") { "Profile Information" }
13
+ p(class: "text-sm text-gray-600 mt-1") { "Update your personal information" }
14
+ end
15
+
16
+ # Profile form
17
+ form(action: EasyAdmin::Engine.routes.url_helpers.update_profile_path, method: :patch) do
18
+ # Rails CSRF token
19
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token) if helpers.respond_to?(:form_authenticity_token)
20
+ # Rails method spoofing for PATCH
21
+ input(type: "hidden", name: "_method", value: "patch")
22
+
23
+ div(class: "space-y-6") do
24
+ # First Name
25
+ div do
26
+ label(for: "admin_user_first_name", class: "block text-sm font-medium text-gray-700 mb-2") { "First Name" }
27
+ input(
28
+ type: "text",
29
+ name: "admin_user[first_name]",
30
+ id: "admin_user_first_name",
31
+ value: @user.first_name,
32
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
33
+ placeholder: "Enter your first name"
34
+ )
35
+ render_field_errors(@user, :first_name)
36
+ end
37
+
38
+ # Last Name
39
+ div do
40
+ label(for: "admin_user_last_name", class: "block text-sm font-medium text-gray-700 mb-2") { "Last Name" }
41
+ input(
42
+ type: "text",
43
+ name: "admin_user[last_name]",
44
+ id: "admin_user_last_name",
45
+ value: @user.last_name,
46
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
47
+ placeholder: "Enter your last name"
48
+ )
49
+ render_field_errors(@user, :last_name)
50
+ end
51
+
52
+ # Email
53
+ div do
54
+ label(for: "admin_user_email", class: "block text-sm font-medium text-gray-700 mb-2") { "Email" }
55
+ input(
56
+ type: "email",
57
+ name: "admin_user[email]",
58
+ id: "admin_user_email",
59
+ value: @user.email,
60
+ class: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
61
+ placeholder: "Enter your email address"
62
+ )
63
+ render_field_errors(@user, :email)
64
+ end
65
+
66
+ # Submit button
67
+ div do
68
+ input(
69
+ type: "submit",
70
+ value: "Update Profile",
71
+ class: "bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors cursor-pointer"
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def render_field_errors(object, field)
82
+ return unless object.errors[field].any?
83
+
84
+ div(class: "mt-1") do
85
+ object.errors[field].each do |error|
86
+ p(class: "text-sm text-red-600") { error }
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,53 @@
1
+ module EasyAdmin
2
+ module Profile
3
+ class SecurityTabComponent < BaseComponent
4
+ def initialize(user:)
5
+ @user = user
6
+ end
7
+
8
+ def view_template
9
+ div(class: "space-y-8") do
10
+ # Password section
11
+ render_password_section
12
+
13
+ # Two-factor authentication section
14
+ div(id: "two_factor_section") do
15
+ render_two_factor_section
16
+ end
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def render_password_section
23
+ div do
24
+ # Section header
25
+ div(class: "mb-6") do
26
+ h2(class: "text-lg font-medium text-gray-900") { "Change Password" }
27
+ p(class: "text-sm text-gray-600 mt-1") { "Update your password to keep your account secure" }
28
+ end
29
+
30
+ # Change password button
31
+ a(
32
+ href: EasyAdmin::Engine.routes.url_helpers.change_password_path,
33
+ class: "bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors",
34
+ data: { turbo_frame: "modal" }
35
+ ) { "Change Password" }
36
+ end
37
+ end
38
+
39
+ def render_two_factor_section
40
+ div(class: "pt-8 border-t border-gray-200") do
41
+ # Section header
42
+ div(class: "mb-6") do
43
+ h2(class: "text-lg font-medium text-gray-900") { "Two-Factor Authentication" }
44
+ p(class: "text-sm text-gray-600 mt-1") { "Add an extra layer of security to your account" }
45
+ end
46
+
47
+ # 2FA status and actions
48
+ render EasyAdmin::TwoFactor::StatusComponent.new(user: @user)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,103 @@
1
+ module EasyAdmin
2
+ module Profile
3
+ class SettingsComponent < BaseComponent
4
+ def initialize(user:)
5
+ @user = user
6
+ end
7
+
8
+ def view_template
9
+ div(class: "min-h-screen bg-gray-50 py-6") do
10
+ div(class: "max-w-6xl mx-auto px-6") do
11
+ # Page header
12
+ div(class: "mb-8") do
13
+ h1(class: "text-2xl font-semibold text-gray-900") { "Settings" }
14
+ p(class: "text-gray-600 mt-1") { "Manage your account settings and preferences" }
15
+ end
16
+
17
+ # Settings layout with vertical tabs
18
+ div(
19
+ class: "bg-white rounded-lg overflow-hidden",
20
+ data: { controller: "vertical-tabs" }
21
+ ) do
22
+ div(class: "flex") do
23
+ # Left sidebar - Tab navigation
24
+ render_tab_navigation
25
+
26
+ # Right content area
27
+ render_content_area
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def render_tab_navigation
37
+ nav(class: "w-64 bg-gray-50 border-r border-gray-200 py-6") do
38
+ div(class: "px-4") do
39
+ render_tab_item("profile", "Profile", profile_icon, active: true)
40
+ render_tab_item("security", "Security", security_icon)
41
+ end
42
+ end
43
+ end
44
+
45
+ def render_tab_item(tab_id, label, icon_svg, active: false)
46
+ button(
47
+ class: "w-full flex items-center px-3 py-2 mb-2 text-left rounded-lg transition-colors #{tab_classes(active)}",
48
+ data: {
49
+ action: "click->vertical-tabs#switchTab",
50
+ vertical_tabs_target: "tab",
51
+ tab_id: tab_id
52
+ }
53
+ ) do
54
+ div(class: "w-5 h-5 mr-3") { unsafe_raw(icon_svg) }
55
+ span(class: "font-medium") { label }
56
+ end
57
+ end
58
+
59
+ def render_content_area
60
+ div(class: "flex-1 p-6") do
61
+ # Profile tab content
62
+ div(
63
+ class: "tab-content",
64
+ data: {
65
+ vertical_tabs_target: "content",
66
+ tab_id: "profile"
67
+ }
68
+ ) do
69
+ render EasyAdmin::Profile::ProfileTabComponent.new(user: @user)
70
+ end
71
+
72
+ # Security tab content
73
+ div(
74
+ class: "tab-content hidden",
75
+ data: {
76
+ vertical_tabs_target: "content",
77
+ tab_id: "security"
78
+ }
79
+ ) do
80
+ render EasyAdmin::Profile::SecurityTabComponent.new(user: @user)
81
+ end
82
+ end
83
+ end
84
+
85
+ def tab_classes(active)
86
+ if active
87
+ "bg-blue-50 text-blue-700 border-blue-200"
88
+ else
89
+ "text-gray-700 hover:bg-gray-100 hover:text-gray-900"
90
+ end
91
+ end
92
+
93
+ # SVG Icons
94
+ def profile_icon
95
+ '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>'
96
+ end
97
+
98
+ def security_icon
99
+ '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/></svg>'
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,118 @@
1
+ module EasyAdmin
2
+ module TwoFactor
3
+ class BackupCodesComponent < BaseComponent
4
+ def initialize(user:, codes: nil)
5
+ @user = user
6
+ @codes = codes # Fresh codes if just generated
7
+ end
8
+
9
+ def view_template
10
+ return render_unavailable unless @user.two_factor_available?
11
+ return render_not_enabled unless @user.two_factor_enabled?
12
+
13
+ if @codes
14
+ render_fresh_codes
15
+ else
16
+ render_existing_codes
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def render_unavailable
23
+ div(class: "text-center") do
24
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "2FA Not Available" }
25
+ p(class: "text-gray-600") { "Two-factor authentication is not available." }
26
+ end
27
+ end
28
+
29
+ def render_not_enabled
30
+ div(class: "text-center") do
31
+ h3(class: "text-lg font-medium text-gray-900 mb-2") { "2FA Not Enabled" }
32
+ p(class: "text-gray-600 mb-4") { "Enable two-factor authentication first to generate backup codes." }
33
+ a(
34
+ href: EasyAdmin::Engine.routes.url_helpers.two_factor_setup_path,
35
+ class: "bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors"
36
+ ) { "Enable 2FA" }
37
+ end
38
+ end
39
+
40
+ def render_fresh_codes
41
+ div do
42
+ div(class: "bg-green-50 border border-green-200 rounded-lg p-4 mb-6") do
43
+ div(class: "flex items-center mb-2") do
44
+ div(class: "w-5 h-5 text-green-500 mr-2") do
45
+ unsafe_raw '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
46
+ end
47
+ h3(class: "text-sm font-medium text-green-900") { "New Backup Codes Generated" }
48
+ end
49
+ p(class: "text-sm text-green-700") do
50
+ "Save these codes in a secure place. Each code can only be used once."
51
+ end
52
+ end
53
+
54
+ render_codes_list(@codes)
55
+ end
56
+ end
57
+
58
+ def render_existing_codes
59
+ div do
60
+ div(class: "mb-6") do
61
+ div(class: "flex items-center justify-between mb-4") do
62
+ span(class: "text-sm text-gray-500") do
63
+ "#{@user.backup_codes_remaining} codes remaining"
64
+ end
65
+ end
66
+
67
+ if @user.backup_codes_remaining == 0
68
+ div(class: "bg-red-50 border border-red-200 rounded-lg p-4 mb-4") do
69
+ div(class: "flex items-center mb-2") do
70
+ div(class: "w-5 h-5 text-red-500 mr-2") do
71
+ unsafe_raw '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>'
72
+ end
73
+ h4(class: "text-sm font-medium text-red-900") { "No Backup Codes Remaining" }
74
+ end
75
+ p(class: "text-sm text-red-700") do
76
+ "You've used all your backup codes. Generate new ones to ensure you can access your account."
77
+ end
78
+ end
79
+ elsif @user.backup_codes_remaining <= 2
80
+ div(class: "bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4") do
81
+ div(class: "flex items-center mb-2") do
82
+ div(class: "w-5 h-5 text-yellow-500 mr-2") do
83
+ unsafe_raw '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.996-.833-2.764 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>'
84
+ end
85
+ h4(class: "text-sm font-medium text-yellow-900") { "Low on Backup Codes" }
86
+ end
87
+ p(class: "text-sm text-yellow-700") do
88
+ "Consider generating new backup codes soon."
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ render_codes_list(@user.otp_backup_codes || [])
95
+ end
96
+ end
97
+
98
+ def render_codes_list(codes)
99
+ if codes.any?
100
+ div(class: "bg-gray-50 p-4 rounded-lg mb-6") do
101
+ div(class: "grid grid-cols-2 gap-2") do
102
+ codes.each do |code|
103
+ div(class: "bg-white p-3 rounded border text-center font-mono text-lg tracking-wider") do
104
+ code
105
+ end
106
+ end
107
+ end
108
+ end
109
+ else
110
+ div(class: "bg-gray-50 p-4 rounded-lg mb-6 text-center") do
111
+ p(class: "text-gray-500 italic") { "No backup codes available" }
112
+ end
113
+ end
114
+ end
115
+
116
+ end
117
+ end
118
+ end