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.
- checksums.yaml +4 -4
- data/app/assets/builds/easy_admin.base.js +88 -0
- data/app/assets/builds/easy_admin.base.js.map +3 -3
- data/app/assets/builds/easy_admin.css +54 -0
- data/app/components/easy_admin/navbar_component.rb +19 -4
- data/app/components/easy_admin/profile/change_password_modal_component.rb +75 -0
- data/app/components/easy_admin/profile/profile_tab_component.rb +92 -0
- data/app/components/easy_admin/profile/security_tab_component.rb +53 -0
- data/app/components/easy_admin/profile/settings_component.rb +103 -0
- data/app/components/easy_admin/two_factor/backup_codes_component.rb +118 -0
- data/app/components/easy_admin/two_factor/setup_component.rb +124 -0
- data/app/components/easy_admin/two_factor/status_component.rb +92 -0
- data/app/controllers/concerns/easy_admin/two_factor.rb +110 -0
- data/app/controllers/easy_admin/application_controller.rb +10 -0
- data/app/controllers/easy_admin/profile_controller.rb +25 -0
- data/app/controllers/easy_admin/sessions_controller.rb +107 -1
- data/app/javascript/easy_admin/controllers/vertical_tabs_controller.js +112 -0
- data/app/javascript/easy_admin/controllers.js +3 -1
- data/app/models/easy_admin/admin_user.rb +3 -0
- data/app/views/easy_admin/profile/backup_codes_regenerated.turbo_stream.erb +12 -0
- data/app/views/easy_admin/profile/change_password.html.erb +24 -0
- data/app/views/easy_admin/profile/index.html.erb +1 -0
- data/app/views/easy_admin/profile/password_error.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/password_invalid_current.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/password_updated.turbo_stream.erb +9 -0
- data/app/views/easy_admin/profile/two_factor_backup_codes.html.erb +24 -0
- data/app/views/easy_admin/profile/two_factor_enabled.turbo_stream.erb +12 -0
- data/app/views/easy_admin/profile/two_factor_invalid_code.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/two_factor_not_enabled.turbo_stream.erb +6 -0
- data/app/views/easy_admin/profile/two_factor_setup.html.erb +24 -0
- data/app/views/easy_admin/profile/two_factor_unavailable.turbo_stream.erb +6 -0
- data/app/views/easy_admin/sessions/two_factor_verification.html.erb +48 -0
- data/app/views/easy_admin/sessions/verify_2fa_error.turbo_stream.erb +13 -0
- data/config/routes.rb +20 -1
- data/lib/easy-admin-rails.rb +1 -0
- data/lib/easy_admin/two_factor_authentication.rb +156 -0
- data/lib/easy_admin/version.rb +1 -1
- data/lib/generators/easy_admin/two_factor/templates/README +29 -0
- data/lib/generators/easy_admin/two_factor/templates/migration.rb +10 -0
- data/lib/generators/easy_admin/two_factor/two_factor_generator.rb +22 -0
- 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("
|
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
|
-
|
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
|
-
|
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
|