rsb-admin 0.9.1
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 +7 -0
- data/LICENSE +15 -0
- data/README.md +83 -0
- data/Rakefile +25 -0
- data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
- data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
- data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
- data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
- data/app/controllers/rsb/admin/admin_controller.rb +138 -0
- data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
- data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
- data/app/controllers/rsb/admin/profile_controller.rb +146 -0
- data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
- data/app/controllers/rsb/admin/resources_controller.rb +386 -0
- data/app/controllers/rsb/admin/roles_controller.rb +99 -0
- data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
- data/app/controllers/rsb/admin/settings_controller.rb +203 -0
- data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
- data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
- data/app/helpers/rsb/admin/branding_helper.rb +38 -0
- data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
- data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
- data/app/helpers/rsb/admin/icons_helper.rb +55 -0
- data/app/helpers/rsb/admin/table_helper.rb +132 -0
- data/app/helpers/rsb/admin/theme_helper.rb +84 -0
- data/app/helpers/rsb/admin/url_helper.rb +109 -0
- data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
- data/app/models/rsb/admin/admin_session.rb +109 -0
- data/app/models/rsb/admin/admin_user.rb +153 -0
- data/app/models/rsb/admin/application_record.rb +10 -0
- data/app/models/rsb/admin/role.rb +63 -0
- data/app/views/layouts/rsb/admin/application.html.erb +45 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
- data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
- data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
- data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
- data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
- data/app/views/rsb/admin/profile/edit.html.erb +67 -0
- data/app/views/rsb/admin/profile/show.html.erb +155 -0
- data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
- data/app/views/rsb/admin/resources/_form.html.erb +20 -0
- data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
- data/app/views/rsb/admin/resources/_table.html.erb +70 -0
- data/app/views/rsb/admin/resources/edit.html.erb +7 -0
- data/app/views/rsb/admin/resources/index.html.erb +49 -0
- data/app/views/rsb/admin/resources/new.html.erb +7 -0
- data/app/views/rsb/admin/resources/page.html.erb +9 -0
- data/app/views/rsb/admin/resources/show.html.erb +55 -0
- data/app/views/rsb/admin/roles/_form.html.erb +197 -0
- data/app/views/rsb/admin/roles/edit.html.erb +7 -0
- data/app/views/rsb/admin/roles/index.html.erb +71 -0
- data/app/views/rsb/admin/roles/new.html.erb +7 -0
- data/app/views/rsb/admin/roles/show.html.erb +99 -0
- data/app/views/rsb/admin/sessions/new.html.erb +31 -0
- data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
- data/app/views/rsb/admin/settings/_field.html.erb +115 -0
- data/app/views/rsb/admin/settings/index.html.erb +61 -0
- data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
- data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
- data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
- data/app/views/rsb/admin/shared/_header.html.erb +50 -0
- data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
- data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
- data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
- data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
- data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
- data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
- data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
- data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
- data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
- data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
- data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
- data/config/locales/en.yml +140 -0
- data/config/locales/seo.en.yml +21 -0
- data/config/routes.rb +59 -0
- data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
- data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
- data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
- data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
- data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
- data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
- data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
- data/lib/rsb/admin/breadcrumb_item.rb +26 -0
- data/lib/rsb/admin/category_registration.rb +177 -0
- data/lib/rsb/admin/column_definition.rb +89 -0
- data/lib/rsb/admin/configuration.rb +69 -0
- data/lib/rsb/admin/engine.rb +34 -0
- data/lib/rsb/admin/filter_definition.rb +129 -0
- data/lib/rsb/admin/form_field_definition.rb +96 -0
- data/lib/rsb/admin/icons.rb +95 -0
- data/lib/rsb/admin/page_registration.rb +140 -0
- data/lib/rsb/admin/registry.rb +109 -0
- data/lib/rsb/admin/resource_dsl_context.rb +139 -0
- data/lib/rsb/admin/resource_registration.rb +287 -0
- data/lib/rsb/admin/settings_schema.rb +60 -0
- data/lib/rsb/admin/test_kit/helpers.rb +316 -0
- data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
- data/lib/rsb/admin/test_kit.rb +11 -0
- data/lib/rsb/admin/theme_definition.rb +46 -0
- data/lib/rsb/admin/themes/modern.rb +44 -0
- data/lib/rsb/admin/version.rb +9 -0
- data/lib/rsb/admin.rb +177 -0
- data/lib/tasks/rsb/admin_tasks.rake +23 -0
- metadata +227 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class AdminUser < ApplicationRecord
|
|
6
|
+
has_secure_password
|
|
7
|
+
|
|
8
|
+
encrypts :otp_secret
|
|
9
|
+
encrypts :otp_backup_codes
|
|
10
|
+
|
|
11
|
+
belongs_to :role, optional: true
|
|
12
|
+
has_many :admin_sessions, dependent: :destroy
|
|
13
|
+
|
|
14
|
+
validates :email, presence: true,
|
|
15
|
+
uniqueness: { case_sensitive: false },
|
|
16
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
17
|
+
validates :password, length: { minimum: 8 }, if: :password_required?
|
|
18
|
+
validate :pending_email_uniqueness, if: -> { pending_email.present? }
|
|
19
|
+
|
|
20
|
+
normalizes :email, with: ->(e) { e.strip.downcase }
|
|
21
|
+
|
|
22
|
+
def record_sign_in!(ip:)
|
|
23
|
+
update_columns(last_sign_in_at: Time.current, last_sign_in_ip: ip)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def can?(resource, action)
|
|
27
|
+
return false unless role # no role = no access
|
|
28
|
+
|
|
29
|
+
role.can?(resource, action)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Initiates email verification by storing the new email as pending
|
|
33
|
+
# and generating a verification token.
|
|
34
|
+
#
|
|
35
|
+
# @param new_email [String] the new email address to verify
|
|
36
|
+
# @return [void]
|
|
37
|
+
# @raise [ActiveRecord::RecordInvalid] if pending_email fails validation
|
|
38
|
+
def generate_email_verification!(new_email)
|
|
39
|
+
update!(
|
|
40
|
+
pending_email: new_email.strip.downcase,
|
|
41
|
+
email_verification_token: SecureRandom.urlsafe_base64(32),
|
|
42
|
+
email_verification_sent_at: Time.current
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Confirms the pending email change by moving pending_email to email
|
|
47
|
+
# and clearing all verification fields.
|
|
48
|
+
#
|
|
49
|
+
# @return [void]
|
|
50
|
+
# @raise [ActiveRecord::RecordInvalid] if the email update fails
|
|
51
|
+
def verify_email!
|
|
52
|
+
update!(
|
|
53
|
+
email: pending_email,
|
|
54
|
+
pending_email: nil,
|
|
55
|
+
email_verification_token: nil,
|
|
56
|
+
email_verification_sent_at: nil
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [Boolean] true if there is a pending email awaiting verification
|
|
61
|
+
def email_verification_pending?
|
|
62
|
+
pending_email.present?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean] true if the verification token has expired
|
|
66
|
+
def email_verification_expired?
|
|
67
|
+
return true unless email_verification_sent_at
|
|
68
|
+
|
|
69
|
+
email_verification_sent_at < RSB::Admin.configuration.email_verification_expiry.ago
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [Boolean] true if TOTP 2FA is fully enabled
|
|
73
|
+
def otp_enabled?
|
|
74
|
+
otp_secret.present? && otp_required?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Generates a new TOTP secret. Does NOT save to database —
|
|
78
|
+
# the secret is returned for QR code display during enrollment.
|
|
79
|
+
#
|
|
80
|
+
# @return [String] base32-encoded TOTP secret
|
|
81
|
+
def generate_otp_secret!
|
|
82
|
+
ROTP::Base32.random
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Verifies a TOTP code against the stored secret.
|
|
86
|
+
#
|
|
87
|
+
# @param code [String] 6-digit TOTP code
|
|
88
|
+
# @return [Boolean] true if code is valid (with 30s drift tolerance)
|
|
89
|
+
def verify_otp(code)
|
|
90
|
+
return false unless otp_secret.present?
|
|
91
|
+
|
|
92
|
+
totp = ROTP::TOTP.new(otp_secret)
|
|
93
|
+
totp.verify(code.to_s, drift_behind: 30, drift_ahead: 30).present?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Generates 10 one-time backup codes. Stores bcrypt hashes in the
|
|
97
|
+
# database. Returns the plaintext codes for one-time display.
|
|
98
|
+
#
|
|
99
|
+
# @return [Array<String>] 10 plaintext backup codes (8 alphanumeric chars each)
|
|
100
|
+
def generate_backup_codes!
|
|
101
|
+
codes = 10.times.map { SecureRandom.alphanumeric(8) }
|
|
102
|
+
hashes = codes.map { |code| BCrypt::Password.create(code) }
|
|
103
|
+
update!(otp_backup_codes: hashes.to_json)
|
|
104
|
+
codes
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Verifies a backup code against stored hashes. If valid, the
|
|
108
|
+
# matching hash is removed (consumed) and the array is saved.
|
|
109
|
+
#
|
|
110
|
+
# @param code [String] plaintext backup code
|
|
111
|
+
# @return [Boolean] true if code matched and was consumed
|
|
112
|
+
def verify_backup_code(code)
|
|
113
|
+
return false unless otp_backup_codes.present?
|
|
114
|
+
|
|
115
|
+
stored = JSON.parse(otp_backup_codes)
|
|
116
|
+
matched_index = stored.index { |hash| BCrypt::Password.new(hash) == code }
|
|
117
|
+
return false unless matched_index
|
|
118
|
+
|
|
119
|
+
stored.delete_at(matched_index)
|
|
120
|
+
update!(otp_backup_codes: stored.to_json)
|
|
121
|
+
true
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Disables TOTP 2FA by clearing all OTP fields.
|
|
125
|
+
#
|
|
126
|
+
# @return [void]
|
|
127
|
+
def disable_otp!
|
|
128
|
+
update!(otp_secret: nil, otp_required: false, otp_backup_codes: nil)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Builds the otpauth:// URI for QR code generation.
|
|
132
|
+
#
|
|
133
|
+
# @param secret [String] base32-encoded TOTP secret
|
|
134
|
+
# @param issuer [String] application name for authenticator app display
|
|
135
|
+
# @return [String] otpauth:// URI
|
|
136
|
+
def otp_provisioning_uri(secret, issuer: 'RSB Admin')
|
|
137
|
+
ROTP::TOTP.new(secret, issuer: issuer).provisioning_uri(email)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def password_required?
|
|
143
|
+
new_record? || password.present?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def pending_email_uniqueness
|
|
147
|
+
return unless self.class.where.not(id: id).exists?(email: pending_email)
|
|
148
|
+
|
|
149
|
+
errors.add(:pending_email, 'has already been taken')
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RSB
|
|
4
|
+
module Admin
|
|
5
|
+
class Role < ApplicationRecord
|
|
6
|
+
has_many :admin_users, dependent: :restrict_with_error
|
|
7
|
+
|
|
8
|
+
validates :name, presence: true, uniqueness: true
|
|
9
|
+
# Permissions column has a database default of {}, so it's never nil in practice
|
|
10
|
+
# Empty permissions hash is valid (role with no access to anything)
|
|
11
|
+
|
|
12
|
+
# Accept permissions as JSON string from forms (legacy)
|
|
13
|
+
def permissions_json=(json_string)
|
|
14
|
+
self.permissions = JSON.parse(json_string)
|
|
15
|
+
rescue JSON::ParserError
|
|
16
|
+
errors.add(:permissions, 'is not valid JSON')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Accept permissions from checkbox form params
|
|
20
|
+
# Expected format: { "rsb_auth_identities" => ["index", "show"], "plans" => ["index"] }
|
|
21
|
+
def permissions_checkboxes=(checkbox_params)
|
|
22
|
+
self.permissions = if checkbox_params.blank?
|
|
23
|
+
{}
|
|
24
|
+
else
|
|
25
|
+
# checkbox_params comes as ActionController::Parameters or Hash
|
|
26
|
+
# Filter out the dummy field (used to ensure param is always sent)
|
|
27
|
+
checkbox_params.to_h
|
|
28
|
+
.reject { |key, _| key.to_s == '_dummy' }
|
|
29
|
+
.transform_values do |actions|
|
|
30
|
+
Array(actions).map(&:to_s).reject(&:blank?)
|
|
31
|
+
end
|
|
32
|
+
.reject { |_, actions| actions.empty? }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Set superadmin permissions when toggle is "1"
|
|
37
|
+
def superadmin_toggle=(value)
|
|
38
|
+
return unless ['1', true].include?(value)
|
|
39
|
+
|
|
40
|
+
self.permissions = { '*' => ['*'] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Permissions format:
|
|
44
|
+
# {
|
|
45
|
+
# "identities" => ["index", "show"],
|
|
46
|
+
# "plans" => ["index", "show", "new", "create", "edit", "update"],
|
|
47
|
+
# "settings" => ["index", "update"],
|
|
48
|
+
# "*" => ["*"] # superadmin
|
|
49
|
+
# }
|
|
50
|
+
|
|
51
|
+
def can?(resource, action)
|
|
52
|
+
return true if superadmin?
|
|
53
|
+
|
|
54
|
+
allowed = permissions[resource.to_s] || []
|
|
55
|
+
allowed.include?(action.to_s) || allowed.include?('*')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def superadmin?
|
|
59
|
+
permissions['*']&.include?('*') || false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<%= rsb_seo_meta_tags %>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<%= rsb_seo_head_tags %>
|
|
8
|
+
<%= yield :rsb_head %>
|
|
9
|
+
<% theme = RSB::Admin.current_theme %>
|
|
10
|
+
<%= stylesheet_link_tag theme.css, media: "all" %>
|
|
11
|
+
<% if theme.js %>
|
|
12
|
+
<%= javascript_include_tag theme.js %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</head>
|
|
15
|
+
<body class="bg-rsb-bg text-rsb-text font-sans">
|
|
16
|
+
<% admin_user = respond_to?(:current_admin_user) ? current_admin_user : nil %>
|
|
17
|
+
<% if admin_user %>
|
|
18
|
+
<div class="flex min-h-screen">
|
|
19
|
+
<aside class="w-[250px] bg-rsb-sidebar text-rsb-sidebar-text fixed top-0 left-0 bottom-0 overflow-y-auto py-4">
|
|
20
|
+
<%= render rsb_admin_partial("shared/sidebar") %>
|
|
21
|
+
</aside>
|
|
22
|
+
<div class="ml-[250px] flex-1 min-h-screen">
|
|
23
|
+
<header class="bg-rsb-card border-b border-rsb-border px-6 h-14 flex items-center justify-between">
|
|
24
|
+
<%= render rsb_admin_partial("shared/header") %>
|
|
25
|
+
</header>
|
|
26
|
+
<main class="p-6">
|
|
27
|
+
<%= render rsb_admin_partial("shared/flash") %>
|
|
28
|
+
<%= render rsb_admin_partial("shared/breadcrumbs") %>
|
|
29
|
+
<%= yield %>
|
|
30
|
+
</main>
|
|
31
|
+
<% if respond_to?(:rsb_admin_footer_text) && rsb_admin_footer_text.present? %>
|
|
32
|
+
<footer class="px-6 py-3 text-xs text-rsb-muted border-t border-rsb-border">
|
|
33
|
+
<%= rsb_admin_footer_text %>
|
|
34
|
+
</footer>
|
|
35
|
+
<% end %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<% else %>
|
|
39
|
+
<%= render rsb_admin_partial("shared/flash") %>
|
|
40
|
+
<%= yield %>
|
|
41
|
+
<% end %>
|
|
42
|
+
<%= rsb_seo_body_tags %>
|
|
43
|
+
<%= yield :rsb_body %>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<h2>Verify your new email address</h2>
|
|
5
|
+
<p>You requested to change your admin email to <strong><%= @admin_user.pending_email %></strong>.</p>
|
|
6
|
+
<p>Click the link below to verify this email address:</p>
|
|
7
|
+
<p><a href="<%= @verification_url %>"><%= @verification_url %></a></p>
|
|
8
|
+
<p>This link will expire in 24 hours.</p>
|
|
9
|
+
<p>If you did not request this change, you can ignore this email.</p>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Verify your new email address
|
|
2
|
+
|
|
3
|
+
You requested to change your admin email to <%= @admin_user.pending_email %>.
|
|
4
|
+
|
|
5
|
+
Click the link below to verify this email address:
|
|
6
|
+
|
|
7
|
+
<%= @verification_url %>
|
|
8
|
+
|
|
9
|
+
This link will expire in 24 hours.
|
|
10
|
+
|
|
11
|
+
If you did not request this change, you can ignore this email.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<%= form_with model: admin_user, url: url, method: method, local: true, scope: :admin_user do |f| %>
|
|
2
|
+
<% if admin_user.errors.any? %>
|
|
3
|
+
<div class="bg-rsb-danger-bg text-rsb-danger-text border border-red-200 px-4 py-3 rounded-rsb mb-4 text-sm">
|
|
4
|
+
<strong><%= rsb_admin_t("errors.prohibited", count: admin_user.errors.count) %></strong>
|
|
5
|
+
<ul class="mt-2 pl-6 list-disc">
|
|
6
|
+
<% admin_user.errors.full_messages.each do |message| %>
|
|
7
|
+
<li><%= message %></li>
|
|
8
|
+
<% end %>
|
|
9
|
+
</ul>
|
|
10
|
+
</div>
|
|
11
|
+
<% end %>
|
|
12
|
+
|
|
13
|
+
<div class="mb-4">
|
|
14
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("columns.email") %></label>
|
|
15
|
+
<%= f.email_field :email, autofocus: true, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="mb-4">
|
|
19
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("sessions.password") %></label>
|
|
20
|
+
<%= f.password_field :password, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
21
|
+
<% if admin_user.persisted? %>
|
|
22
|
+
<p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("admin_users.leave_blank") %></p>
|
|
23
|
+
<% else %>
|
|
24
|
+
<p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("admin_users.min_password") %></p>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<div class="mb-4">
|
|
29
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("admin_users.confirm_password") %></label>
|
|
30
|
+
<%= f.password_field :password_confirmation, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="mb-4">
|
|
34
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("admin_users.role") %></label>
|
|
35
|
+
<%= f.select :role_id,
|
|
36
|
+
RSB::Admin::Role.order(:name).map { |r| [r.name, r.id] },
|
|
37
|
+
{ include_blank: rsb_admin_t("admin_users.no_role") },
|
|
38
|
+
class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
39
|
+
<p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("admin_users.no_role_hint") %></p>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div class="flex gap-3 mt-6">
|
|
43
|
+
<button type="submit"
|
|
44
|
+
class="px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
|
|
45
|
+
<%= rsb_admin_t("shared.save") %>
|
|
46
|
+
</button>
|
|
47
|
+
<a href="<%= rsb_admin.admin_users_path %>"
|
|
48
|
+
class="px-4 py-2 border border-rsb-border text-rsb-text rounded-rsb text-sm hover:bg-rsb-bg">
|
|
49
|
+
<%= rsb_admin_t("shared.cancel") %>
|
|
50
|
+
</a>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= rsb_admin_t("shared.edit") %> <%= rsb_admin_t("admin_users.title").singularize %></h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
6
|
+
<%= render rsb_admin_partial("admin_users/form"),
|
|
7
|
+
admin_user: @admin_user,
|
|
8
|
+
url: rsb_admin.admin_user_path(@admin_user),
|
|
9
|
+
method: :patch %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= rsb_admin_t("admin_users.title") %></h1>
|
|
3
|
+
<% if rsb_admin_can?("admin_users", "new") %>
|
|
4
|
+
<a href="<%= rsb_admin.new_admin_user_path %>"
|
|
5
|
+
class="inline-flex items-center gap-1 px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover transition-colors">
|
|
6
|
+
<%= rsb_admin_icon("plus", size: 16) %>
|
|
7
|
+
<%= rsb_admin_t("shared.new", resource: rsb_admin_t("admin_users.title").singularize) %>
|
|
8
|
+
</a>
|
|
9
|
+
<% else %>
|
|
10
|
+
<span class="inline-flex items-center gap-1 px-4 py-2 bg-rsb-primary/50 text-rsb-primary-text/50 rounded-rsb text-sm font-medium pointer-events-none cursor-not-allowed"
|
|
11
|
+
title="<%= rsb_admin_t("shared.no_access") %>">
|
|
12
|
+
<%= rsb_admin_icon("plus", size: 16) %>
|
|
13
|
+
<%= rsb_admin_t("shared.new", resource: rsb_admin_t("admin_users.title").singularize) %>
|
|
14
|
+
</span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm overflow-hidden">
|
|
19
|
+
<% if @admin_users.any? %>
|
|
20
|
+
<div class="overflow-x-auto">
|
|
21
|
+
<table class="w-full">
|
|
22
|
+
<thead>
|
|
23
|
+
<tr>
|
|
24
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"><%= rsb_admin_t("columns.email") %></th>
|
|
25
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"><%= rsb_admin_t("roles.title") %></th>
|
|
26
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"><%= rsb_admin_t("admin_users.last_sign_in") %></th>
|
|
27
|
+
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"><%= rsb_admin_t("admin_users.last_ip") %></th>
|
|
28
|
+
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-rsb-muted bg-rsb-bg border-b border-rsb-border"></th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
<% @admin_users.each do |admin_user| %>
|
|
33
|
+
<tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
|
|
34
|
+
<td class="px-4 py-3 text-sm">
|
|
35
|
+
<a href="<%= rsb_admin.admin_user_path(admin_user) %>" class="text-rsb-primary hover:underline"><%= admin_user.email %></a>
|
|
36
|
+
<% if admin_user == current_admin_user %>
|
|
37
|
+
<%= rsb_admin_badge(rsb_admin_t("admin_users.you"), variant: :success) %>
|
|
38
|
+
<% end %>
|
|
39
|
+
</td>
|
|
40
|
+
<td class="px-4 py-3 text-sm">
|
|
41
|
+
<% if admin_user.role %>
|
|
42
|
+
<a href="<%= rsb_admin.role_path(admin_user.role) %>" class="text-rsb-primary hover:underline"><%= admin_user.role.name %></a>
|
|
43
|
+
<% else %>
|
|
44
|
+
<%= rsb_admin_badge(rsb_admin_t("admin_users.no_role"), variant: :warning) %>
|
|
45
|
+
<% end %>
|
|
46
|
+
</td>
|
|
47
|
+
<td class="px-4 py-3 text-sm">
|
|
48
|
+
<% if admin_user.last_sign_in_at %>
|
|
49
|
+
<%= admin_user.last_sign_in_at.strftime("%b %d, %Y %H:%M") %>
|
|
50
|
+
<% else %>
|
|
51
|
+
<span class="text-rsb-muted"><%= rsb_admin_t("admin_users.never") %></span>
|
|
52
|
+
<% end %>
|
|
53
|
+
</td>
|
|
54
|
+
<td class="px-4 py-3 text-sm"><%= admin_user.last_sign_in_ip || "-" %></td>
|
|
55
|
+
<td class="px-4 py-3 text-sm text-right">
|
|
56
|
+
<% if rsb_admin_can?("admin_users", "edit") %>
|
|
57
|
+
<a href="<%= rsb_admin.edit_admin_user_path(admin_user) %>"
|
|
58
|
+
class="text-rsb-muted hover:text-rsb-text" title="<%= rsb_admin_t("shared.edit") %>">
|
|
59
|
+
<%= rsb_admin_icon("edit", size: 16) %>
|
|
60
|
+
</a>
|
|
61
|
+
<% else %>
|
|
62
|
+
<span class="text-rsb-muted/40 cursor-not-allowed" title="<%= rsb_admin_t("shared.no_access") %>">
|
|
63
|
+
<%= rsb_admin_icon("edit", size: 16) %>
|
|
64
|
+
</span>
|
|
65
|
+
<% end %>
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
<% end %>
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
<% else %>
|
|
73
|
+
<%= render rsb_admin_partial("shared/empty_state"),
|
|
74
|
+
title: rsb_admin_t("shared.no_results", resource: rsb_admin_t("admin_users.title")),
|
|
75
|
+
message: rsb_admin_t("shared.no_results_message", resource: rsb_admin_t("admin_users.title")) %>
|
|
76
|
+
<% end %>
|
|
77
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= rsb_admin_t("shared.new", resource: rsb_admin_t("admin_users.title").singularize) %></h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
6
|
+
<%= render rsb_admin_partial("admin_users/form"),
|
|
7
|
+
admin_user: @admin_user,
|
|
8
|
+
url: rsb_admin.admin_users_path,
|
|
9
|
+
method: :post %>
|
|
10
|
+
</div>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= @admin_user.email %></h1>
|
|
3
|
+
<div class="flex gap-2">
|
|
4
|
+
<% if rsb_admin_can?("admin_users", "edit") %>
|
|
5
|
+
<a href="<%= rsb_admin.edit_admin_user_path(@admin_user) %>"
|
|
6
|
+
class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg">
|
|
7
|
+
<%= rsb_admin_icon("edit", size: 16) %>
|
|
8
|
+
<%= rsb_admin_t("shared.edit") %>
|
|
9
|
+
</a>
|
|
10
|
+
<% else %>
|
|
11
|
+
<span class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border/50 rounded-rsb text-sm text-rsb-muted pointer-events-none cursor-not-allowed"
|
|
12
|
+
title="<%= rsb_admin_t("shared.no_access") %>">
|
|
13
|
+
<%= rsb_admin_icon("edit", size: 16) %>
|
|
14
|
+
<%= rsb_admin_t("shared.edit") %>
|
|
15
|
+
</span>
|
|
16
|
+
<% end %>
|
|
17
|
+
<a href="<%= rsb_admin.admin_users_path %>"
|
|
18
|
+
class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg">
|
|
19
|
+
<%= rsb_admin_t("shared.back") %>
|
|
20
|
+
</a>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
25
|
+
<div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
|
|
26
|
+
<strong class="text-sm text-rsb-muted"><%= rsb_admin_t("columns.email") %></strong>
|
|
27
|
+
<div><%= @admin_user.email %></div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
|
|
31
|
+
<strong class="text-sm text-rsb-muted"><%= rsb_admin_t("roles.title") %></strong>
|
|
32
|
+
<div>
|
|
33
|
+
<% if @admin_user.role %>
|
|
34
|
+
<a href="<%= rsb_admin.role_path(@admin_user.role) %>" class="text-rsb-primary hover:underline"><%= @admin_user.role.name %></a>
|
|
35
|
+
<% if @admin_user.role.superadmin? %>
|
|
36
|
+
<%= rsb_admin_badge(rsb_admin_t("roles.superadmin"), variant: :success) %>
|
|
37
|
+
<% end %>
|
|
38
|
+
<% else %>
|
|
39
|
+
<%= rsb_admin_badge(rsb_admin_t("admin_users.no_role"), variant: :warning) %>
|
|
40
|
+
<% end %>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
|
|
45
|
+
<strong class="text-sm text-rsb-muted"><%= rsb_admin_t("admin_users.last_sign_in") %></strong>
|
|
46
|
+
<div>
|
|
47
|
+
<% if @admin_user.last_sign_in_at %>
|
|
48
|
+
<%= @admin_user.last_sign_in_at.strftime("%B %d, %Y at %I:%M %p") %>
|
|
49
|
+
<% else %>
|
|
50
|
+
<span class="text-rsb-muted"><%= rsb_admin_t("admin_users.never_signed_in") %></span>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
|
|
56
|
+
<strong class="text-sm text-rsb-muted"><%= rsb_admin_t("admin_users.last_ip") %></strong>
|
|
57
|
+
<div><%= @admin_user.last_sign_in_ip || "-" %></div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="grid grid-cols-[200px_1fr] gap-4 py-3">
|
|
61
|
+
<strong class="text-sm text-rsb-muted"><%= rsb_admin_t("columns.created_at") %></strong>
|
|
62
|
+
<div><%= @admin_user.created_at.strftime("%B %d, %Y at %I:%M %p") %></div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<% unless @admin_user == current_admin_user %>
|
|
67
|
+
<% if rsb_admin_can?("admin_users", "destroy") %>
|
|
68
|
+
<div class="mt-6 bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
69
|
+
<h3 class="text-base font-semibold text-rsb-danger mb-4"><%= rsb_admin_t("shared.danger_zone") %></h3>
|
|
70
|
+
<%= button_to rsb_admin_t("shared.delete") + " " + rsb_admin_t("admin_users.title").singularize,
|
|
71
|
+
rsb_admin.admin_user_path(@admin_user),
|
|
72
|
+
method: :delete,
|
|
73
|
+
data: { turbo_confirm: rsb_admin_t("admin_users.confirm_delete") },
|
|
74
|
+
class: "px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:bg-red-700" %>
|
|
75
|
+
</div>
|
|
76
|
+
<% else %>
|
|
77
|
+
<div class="mt-6 bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
78
|
+
<h3 class="text-base font-semibold text-rsb-danger mb-4"><%= rsb_admin_t("shared.danger_zone") %></h3>
|
|
79
|
+
<span class="inline-flex px-4 py-2 bg-rsb-danger/50 text-white/50 rounded-rsb text-sm font-medium pointer-events-none cursor-not-allowed"
|
|
80
|
+
title="<%= rsb_admin_t("shared.no_access") %>">
|
|
81
|
+
<%= rsb_admin_t("shared.delete") %> <%= rsb_admin_t("admin_users.title").singularize %>
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
<% end %>
|
|
85
|
+
<% end %>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= rsb_admin_t("dashboard.title") %></h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
6
|
+
<h2 class="text-base font-semibold mb-2"><%= rsb_admin_t("dashboard.guide_title") %></h2>
|
|
7
|
+
<p class="text-sm text-rsb-muted mb-4"><%= rsb_admin_t("dashboard.guide_description") %></p>
|
|
8
|
+
|
|
9
|
+
<div class="bg-rsb-bg border border-rsb-border rounded-rsb p-4 mb-4">
|
|
10
|
+
<p class="text-xs text-rsb-muted mb-2"><%= rsb_admin_t("dashboard.guide_step1") %></p>
|
|
11
|
+
<pre class="text-sm overflow-x-auto"><code># config/initializers/rsb_admin.rb
|
|
12
|
+
|
|
13
|
+
RSB::Admin.registry.register_dashboard(
|
|
14
|
+
controller: "admin/dashboard",
|
|
15
|
+
actions: [
|
|
16
|
+
{ key: :index, label: "Overview" },
|
|
17
|
+
{ key: :metrics, label: "Metrics" }
|
|
18
|
+
]
|
|
19
|
+
)</code></pre>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="bg-rsb-bg border border-rsb-border rounded-rsb p-4">
|
|
23
|
+
<p class="text-xs text-rsb-muted mb-2"><%= rsb_admin_t("dashboard.guide_step2") %></p>
|
|
24
|
+
<pre class="text-sm overflow-x-auto"><code># app/controllers/admin/dashboard_controller.rb
|
|
25
|
+
|
|
26
|
+
class Admin::DashboardController < RSB::Admin::AdminController
|
|
27
|
+
def index
|
|
28
|
+
@stats = { users: User.count, revenue: Order.sum(:total) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def metrics
|
|
32
|
+
@metrics = Metric.recent
|
|
33
|
+
end
|
|
34
|
+
end</code></pre>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<div class="flex justify-between items-center mb-6">
|
|
2
|
+
<h1 class="text-2xl font-bold"><%= rsb_admin_t("profile.edit_title") %></h1>
|
|
3
|
+
<a href="<%= rsb_admin.profile_path %>"
|
|
4
|
+
class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg">
|
|
5
|
+
<%= rsb_admin_t("shared.back") %>
|
|
6
|
+
</a>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
|
|
10
|
+
<%= form_with model: @admin_user, url: rsb_admin.profile_path, method: :patch, local: true, scope: :admin_user do |f| %>
|
|
11
|
+
<% if @admin_user.errors.any? %>
|
|
12
|
+
<div class="bg-rsb-danger-bg text-rsb-danger-text border border-red-200 px-4 py-3 rounded-rsb mb-4 text-sm">
|
|
13
|
+
<strong><%= rsb_admin_t("errors.prohibited", count: @admin_user.errors.count) %></strong>
|
|
14
|
+
<ul class="mt-2 pl-6 list-disc">
|
|
15
|
+
<% @admin_user.errors.full_messages.each do |message| %>
|
|
16
|
+
<li><%= message %></li>
|
|
17
|
+
<% end %>
|
|
18
|
+
</ul>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
22
|
+
<div class="mb-4">
|
|
23
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("profile.current_email") %></label>
|
|
24
|
+
<%= f.email_field :email, autofocus: true, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
25
|
+
<% if @admin_user.email_verification_pending? %>
|
|
26
|
+
<p class="text-xs text-rsb-warning mt-1">
|
|
27
|
+
<%= rsb_admin_t("profile.pending_email_hint", email: @admin_user.pending_email) %>
|
|
28
|
+
</p>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<div class="border-t border-rsb-border mt-6 pt-6">
|
|
33
|
+
<h3 class="text-sm font-semibold mb-4"><%= rsb_admin_t("profile.new_password") %></h3>
|
|
34
|
+
|
|
35
|
+
<div class="mb-4">
|
|
36
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("profile.new_password") %></label>
|
|
37
|
+
<%= f.password_field :password, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
38
|
+
<p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("profile.new_password_hint") %></p>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="mb-4">
|
|
42
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("profile.confirm_password") %></label>
|
|
43
|
+
<%= f.password_field :password_confirmation, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="border-t border-rsb-border mt-6 pt-6">
|
|
48
|
+
<div class="mb-4">
|
|
49
|
+
<label class="block text-sm font-medium mb-1"><%= rsb_admin_t("profile.current_password") %></label>
|
|
50
|
+
<input type="password" name="current_password" required
|
|
51
|
+
class="w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10">
|
|
52
|
+
<p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("profile.current_password_hint") %></p>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div class="flex gap-3 mt-6">
|
|
57
|
+
<button type="submit"
|
|
58
|
+
class="px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
|
|
59
|
+
<%= rsb_admin_t("shared.save") %>
|
|
60
|
+
</button>
|
|
61
|
+
<a href="<%= rsb_admin.profile_path %>"
|
|
62
|
+
class="px-4 py-2 border border-rsb-border text-rsb-text rounded-rsb text-sm hover:bg-rsb-bg">
|
|
63
|
+
<%= rsb_admin_t("shared.cancel") %>
|
|
64
|
+
</a>
|
|
65
|
+
</div>
|
|
66
|
+
<% end %>
|
|
67
|
+
</div>
|