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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +83 -0
  4. data/Rakefile +25 -0
  5. data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
  6. data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
  7. data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
  8. data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
  9. data/app/controllers/rsb/admin/admin_controller.rb +138 -0
  10. data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
  11. data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
  12. data/app/controllers/rsb/admin/profile_controller.rb +146 -0
  13. data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
  14. data/app/controllers/rsb/admin/resources_controller.rb +386 -0
  15. data/app/controllers/rsb/admin/roles_controller.rb +99 -0
  16. data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
  17. data/app/controllers/rsb/admin/settings_controller.rb +203 -0
  18. data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
  19. data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
  20. data/app/helpers/rsb/admin/branding_helper.rb +38 -0
  21. data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
  22. data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
  23. data/app/helpers/rsb/admin/icons_helper.rb +55 -0
  24. data/app/helpers/rsb/admin/table_helper.rb +132 -0
  25. data/app/helpers/rsb/admin/theme_helper.rb +84 -0
  26. data/app/helpers/rsb/admin/url_helper.rb +109 -0
  27. data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
  28. data/app/models/rsb/admin/admin_session.rb +109 -0
  29. data/app/models/rsb/admin/admin_user.rb +153 -0
  30. data/app/models/rsb/admin/application_record.rb +10 -0
  31. data/app/models/rsb/admin/role.rb +63 -0
  32. data/app/views/layouts/rsb/admin/application.html.erb +45 -0
  33. data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
  34. data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
  35. data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
  36. data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
  37. data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
  38. data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
  39. data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
  40. data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
  41. data/app/views/rsb/admin/profile/edit.html.erb +67 -0
  42. data/app/views/rsb/admin/profile/show.html.erb +155 -0
  43. data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
  44. data/app/views/rsb/admin/resources/_form.html.erb +20 -0
  45. data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
  46. data/app/views/rsb/admin/resources/_table.html.erb +70 -0
  47. data/app/views/rsb/admin/resources/edit.html.erb +7 -0
  48. data/app/views/rsb/admin/resources/index.html.erb +49 -0
  49. data/app/views/rsb/admin/resources/new.html.erb +7 -0
  50. data/app/views/rsb/admin/resources/page.html.erb +9 -0
  51. data/app/views/rsb/admin/resources/show.html.erb +55 -0
  52. data/app/views/rsb/admin/roles/_form.html.erb +197 -0
  53. data/app/views/rsb/admin/roles/edit.html.erb +7 -0
  54. data/app/views/rsb/admin/roles/index.html.erb +71 -0
  55. data/app/views/rsb/admin/roles/new.html.erb +7 -0
  56. data/app/views/rsb/admin/roles/show.html.erb +99 -0
  57. data/app/views/rsb/admin/sessions/new.html.erb +31 -0
  58. data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
  59. data/app/views/rsb/admin/settings/_field.html.erb +115 -0
  60. data/app/views/rsb/admin/settings/index.html.erb +61 -0
  61. data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
  62. data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
  63. data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
  64. data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
  65. data/app/views/rsb/admin/shared/_header.html.erb +50 -0
  66. data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
  67. data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
  68. data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
  69. data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
  70. data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
  71. data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
  72. data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
  73. data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
  74. data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
  75. data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
  76. data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
  77. data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
  78. data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
  79. data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
  80. data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
  81. data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
  82. data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
  83. data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
  84. data/config/locales/en.yml +140 -0
  85. data/config/locales/seo.en.yml +21 -0
  86. data/config/routes.rb +59 -0
  87. data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
  88. data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
  89. data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
  90. data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
  91. data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
  92. data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
  93. data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
  94. data/lib/rsb/admin/breadcrumb_item.rb +26 -0
  95. data/lib/rsb/admin/category_registration.rb +177 -0
  96. data/lib/rsb/admin/column_definition.rb +89 -0
  97. data/lib/rsb/admin/configuration.rb +69 -0
  98. data/lib/rsb/admin/engine.rb +34 -0
  99. data/lib/rsb/admin/filter_definition.rb +129 -0
  100. data/lib/rsb/admin/form_field_definition.rb +96 -0
  101. data/lib/rsb/admin/icons.rb +95 -0
  102. data/lib/rsb/admin/page_registration.rb +140 -0
  103. data/lib/rsb/admin/registry.rb +109 -0
  104. data/lib/rsb/admin/resource_dsl_context.rb +139 -0
  105. data/lib/rsb/admin/resource_registration.rb +287 -0
  106. data/lib/rsb/admin/settings_schema.rb +60 -0
  107. data/lib/rsb/admin/test_kit/helpers.rb +316 -0
  108. data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
  109. data/lib/rsb/admin/test_kit.rb +11 -0
  110. data/lib/rsb/admin/theme_definition.rb +46 -0
  111. data/lib/rsb/admin/themes/modern.rb +44 -0
  112. data/lib/rsb/admin/version.rb +9 -0
  113. data/lib/rsb/admin.rb +177 -0
  114. data/lib/tasks/rsb/admin_tasks.rake +23 -0
  115. 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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ self.table_name_prefix = 'rsb_admin_'
8
+ end
9
+ end
10
+ 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>