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,197 @@
1
+ <%= form_with model: role, url: url, method: method, local: true do |f| %>
2
+ <% if role.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: role.errors.count) %></strong>
5
+ <ul class="mt-2 pl-6 list-disc">
6
+ <% role.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.name") %></label>
15
+ <%= f.text_field :name, 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="flex items-center gap-2 font-semibold text-sm">
20
+ <input type="checkbox" name="role[superadmin_toggle]" value="1"
21
+ id="superadmin-toggle"
22
+ class="rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
23
+ <%= 'checked' if role.superadmin? %>>
24
+ <span><%= rsb_admin_t("roles.superadmin") %></span>
25
+ </label>
26
+ <p class="text-xs text-rsb-muted mt-1"><%= rsb_admin_t("roles.superadmin_toggle") %></p>
27
+ </div>
28
+
29
+ <div id="permissions-grid" style="<%= 'display: none;' if role.superadmin? %>">
30
+ <label class="block text-sm font-semibold mb-3"><%= rsb_admin_t("roles.permissions") %></label>
31
+
32
+ <input type="hidden" name="role[permissions_checkboxes][_dummy][]" value="" />
33
+
34
+ <% registry = RSB::Admin.registry %>
35
+ <% registry.categories.each do |category_name, category| %>
36
+ <% next if category.resources.empty? && category.pages.empty? %>
37
+
38
+ <div class="mb-6">
39
+ <h4 class="text-xs uppercase tracking-wider text-rsb-muted mb-2"><%= category_name %></h4>
40
+
41
+ <div class="border border-rsb-border rounded-rsb overflow-hidden">
42
+ <% items_index = 0 %>
43
+ <% category.resources.each do |resource| %>
44
+ <% resource_key = resource.route_key %>
45
+ <% allowed_actions = role.permissions[resource_key] || [] %>
46
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap <%= 'border-t border-rsb-border' if items_index > 0 %>">
47
+ <strong class="min-w-[150px] text-sm"><%= resource.label %></strong>
48
+ <div class="flex gap-3 flex-wrap">
49
+ <% resource.actions.each do |action| %>
50
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
51
+ <input type="checkbox"
52
+ name="role[permissions_checkboxes][<%= resource_key %>][]"
53
+ value="<%= action %>"
54
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
55
+ <%= 'checked' if allowed_actions.include?(action.to_s) || role.superadmin? %>>
56
+ <span><%= action.to_s %></span>
57
+ </label>
58
+ <% end %>
59
+ </div>
60
+ </div>
61
+ <% items_index += 1 %>
62
+ <% end %>
63
+ <% category.pages.each do |page| %>
64
+ <% page_key = page.key.to_s %>
65
+ <% page_actions = role.permissions[page_key] || [] %>
66
+ <% page_action_keys = page.action_keys.map(&:to_s) %>
67
+ <% page_action_keys = %w[index] if page_action_keys.empty? %>
68
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap <%= 'border-t border-rsb-border' if items_index > 0 %>">
69
+ <strong class="min-w-[150px] text-sm"><%= page.label %></strong>
70
+ <div class="flex gap-3 flex-wrap">
71
+ <% page_action_keys.each do |action| %>
72
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
73
+ <input type="checkbox"
74
+ name="role[permissions_checkboxes][<%= page_key %>][]"
75
+ value="<%= action %>"
76
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
77
+ <%= 'checked' if page_actions.include?(action) || role.superadmin? %>>
78
+ <span><%= action %></span>
79
+ </label>
80
+ <% end %>
81
+ </div>
82
+ </div>
83
+ <% items_index += 1 %>
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+ <% end %>
88
+
89
+ <div class="mb-6">
90
+ <h4 class="text-xs uppercase tracking-wider text-rsb-muted mb-2"><%= rsb_admin_t("shared.system") %></h4>
91
+
92
+ <div class="border border-rsb-border rounded-rsb overflow-hidden">
93
+ <% dashboard_actions = role.permissions["dashboard"] || [] %>
94
+ <% dashboard_page = RSB::Admin.registry.dashboard_page %>
95
+ <% dashboard_action_keys = dashboard_page&.action_keys&.map(&:to_s) || [] %>
96
+ <% dashboard_action_keys = %w[index] if dashboard_action_keys.empty? %>
97
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap">
98
+ <strong class="min-w-[150px] text-sm"><%= rsb_admin_t("roles.dashboard") %></strong>
99
+ <div class="flex gap-3 flex-wrap">
100
+ <% dashboard_action_keys.each do |action| %>
101
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
102
+ <input type="checkbox"
103
+ name="role[permissions_checkboxes][dashboard][]"
104
+ value="<%= action %>"
105
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
106
+ <%= 'checked' if dashboard_actions.include?(action) || role.superadmin? %>>
107
+ <span><%= action %></span>
108
+ </label>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+
113
+ <% settings_actions = role.permissions["settings"] || [] %>
114
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap border-t border-rsb-border">
115
+ <strong class="min-w-[150px] text-sm"><%= rsb_admin_t("settings.title") %></strong>
116
+ <div class="flex gap-3 flex-wrap">
117
+ <% %w[index update].each do |action| %>
118
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
119
+ <input type="checkbox"
120
+ name="role[permissions_checkboxes][settings][]"
121
+ value="<%= action %>"
122
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
123
+ <%= 'checked' if settings_actions.include?(action) || role.superadmin? %>>
124
+ <span><%= action %></span>
125
+ </label>
126
+ <% end %>
127
+ </div>
128
+ </div>
129
+
130
+ <% admin_users_actions = role.permissions["admin_users"] || [] %>
131
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap border-t border-rsb-border">
132
+ <strong class="min-w-[150px] text-sm"><%= rsb_admin_t("admin_users.title") %></strong>
133
+ <div class="flex gap-3 flex-wrap">
134
+ <% %w[index show new create edit update destroy].each do |action| %>
135
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
136
+ <input type="checkbox"
137
+ name="role[permissions_checkboxes][admin_users][]"
138
+ value="<%= action %>"
139
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
140
+ <%= 'checked' if admin_users_actions.include?(action) || role.superadmin? %>>
141
+ <span><%= action %></span>
142
+ </label>
143
+ <% end %>
144
+ </div>
145
+ </div>
146
+
147
+ <% roles_actions = role.permissions["roles"] || [] %>
148
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap border-t border-rsb-border">
149
+ <strong class="min-w-[150px] text-sm"><%= rsb_admin_t("roles.title") %></strong>
150
+ <div class="flex gap-3 flex-wrap">
151
+ <% %w[index show new create edit update destroy].each do |action| %>
152
+ <label class="flex items-center gap-1 text-xs cursor-pointer">
153
+ <input type="checkbox"
154
+ name="role[permissions_checkboxes][roles][]"
155
+ value="<%= action %>"
156
+ class="permission-checkbox rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary"
157
+ <%= 'checked' if roles_actions.include?(action) || role.superadmin? %>>
158
+ <span><%= action %></span>
159
+ </label>
160
+ <% end %>
161
+ </div>
162
+ </div>
163
+ </div>
164
+ </div>
165
+ </div>
166
+
167
+ <div class="flex gap-3 mt-6">
168
+ <button type="submit"
169
+ class="px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
170
+ <%= rsb_admin_t("shared.save") %>
171
+ </button>
172
+ <a href="<%= rsb_admin.roles_path %>"
173
+ class="px-4 py-2 border border-rsb-border text-rsb-text rounded-rsb text-sm hover:bg-rsb-bg">
174
+ <%= rsb_admin_t("shared.cancel") %>
175
+ </a>
176
+ </div>
177
+ <% end %>
178
+
179
+ <script>
180
+ document.addEventListener('DOMContentLoaded', function() {
181
+ var superadminToggle = document.getElementById('superadmin-toggle');
182
+ var permissionsGrid = document.getElementById('permissions-grid');
183
+ var allCheckboxes = document.querySelectorAll('.permission-checkbox');
184
+
185
+ if (superadminToggle && permissionsGrid) {
186
+ superadminToggle.addEventListener('change', function() {
187
+ if (this.checked) {
188
+ permissionsGrid.style.display = 'none';
189
+ allCheckboxes.forEach(function(cb) { cb.checked = true; });
190
+ } else {
191
+ permissionsGrid.style.display = '';
192
+ allCheckboxes.forEach(function(cb) { cb.checked = false; });
193
+ }
194
+ });
195
+ }
196
+ });
197
+ </script>
@@ -0,0 +1,7 @@
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("roles.title").singularize %>: <%= @role.name %></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("roles/form"), role: @role, url: rsb_admin.role_path(@role), method: :patch %>
7
+ </div>
@@ -0,0 +1,71 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold"><%= rsb_admin_t("roles.title") %></h1>
3
+ <% if rsb_admin_can?("roles", "new") %>
4
+ <a href="<%= rsb_admin.new_role_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("roles.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("roles.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
+ <div class="overflow-x-auto">
20
+ <table class="w-full">
21
+ <thead>
22
+ <tr>
23
+ <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.name") %></th>
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("roles.built_in") %></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.admin_users_count") %></th>
26
+ <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"><%= rsb_admin_t("roles.actions") %></th>
27
+ </tr>
28
+ </thead>
29
+ <tbody>
30
+ <% @roles.each do |role| %>
31
+ <tr class="hover:bg-rsb-bg border-b border-rsb-border last:border-b-0">
32
+ <td class="px-4 py-3 text-sm">
33
+ <a href="<%= rsb_admin.role_path(role) %>" class="text-rsb-primary hover:underline"><%= role.name %></a>
34
+ </td>
35
+ <td class="px-4 py-3 text-sm">
36
+ <% if role.built_in? %>
37
+ <%= rsb_admin_badge(rsb_admin_t("roles.built_in"), variant: :warning) %>
38
+ <% end %>
39
+ </td>
40
+ <td class="px-4 py-3 text-sm"><%= role.admin_users.count %></td>
41
+ <td class="px-4 py-3 text-sm text-right">
42
+ <div class="flex items-center gap-2 justify-end">
43
+ <% if rsb_admin_can?("roles", "edit") %>
44
+ <a href="<%= rsb_admin.edit_role_path(role) %>"
45
+ class="text-rsb-muted hover:text-rsb-text" title="<%= rsb_admin_t("shared.edit") %>">
46
+ <%= rsb_admin_icon("edit", size: 16) %>
47
+ </a>
48
+ <% else %>
49
+ <span class="text-rsb-muted/40 cursor-not-allowed" title="<%= rsb_admin_t("shared.no_access") %>">
50
+ <%= rsb_admin_icon("edit", size: 16) %>
51
+ </span>
52
+ <% end %>
53
+ <% if rsb_admin_can?("roles", "destroy") %>
54
+ <%= button_to rsb_admin.role_path(role), method: :delete,
55
+ data: { turbo_confirm: rsb_admin_t("roles.confirm_delete") },
56
+ class: "text-rsb-muted hover:text-rsb-danger" do %>
57
+ <%= rsb_admin_icon("trash", size: 16) %>
58
+ <% end %>
59
+ <% else %>
60
+ <span class="text-rsb-muted/40 cursor-not-allowed" title="<%= rsb_admin_t("shared.no_access") %>">
61
+ <%= rsb_admin_icon("trash", size: 16) %>
62
+ </span>
63
+ <% end %>
64
+ </div>
65
+ </td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+ </div>
@@ -0,0 +1,7 @@
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("roles.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("roles/form"), role: @role, url: rsb_admin.roles_path, method: :post %>
7
+ </div>
@@ -0,0 +1,99 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold"><%= @role.name %></h1>
3
+ <div class="flex gap-2">
4
+ <% unless @role.built_in? %>
5
+ <% if rsb_admin_can?("roles", "edit") %>
6
+ <a href="<%= rsb_admin.edit_role_path(@role) %>"
7
+ class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg">
8
+ <%= rsb_admin_icon("edit", size: 16) %>
9
+ <%= rsb_admin_t("shared.edit") %>
10
+ </a>
11
+ <% else %>
12
+ <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"
13
+ title="<%= rsb_admin_t("shared.no_access") %>">
14
+ <%= rsb_admin_icon("edit", size: 16) %>
15
+ <%= rsb_admin_t("shared.edit") %>
16
+ </span>
17
+ <% end %>
18
+ <% end %>
19
+ <a href="<%= rsb_admin.roles_path %>"
20
+ class="inline-flex items-center gap-1 px-4 py-2 border border-rsb-border rounded-rsb text-sm hover:bg-rsb-bg">
21
+ <%= rsb_admin_t("shared.back") %>
22
+ </a>
23
+ </div>
24
+ </div>
25
+
26
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
27
+ <div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
28
+ <strong class="text-sm text-rsb-muted"><%= rsb_admin_t("columns.name") %></strong>
29
+ <div><%= @role.name %></div>
30
+ </div>
31
+
32
+ <div class="grid grid-cols-[200px_1fr] gap-4 py-3 border-b border-rsb-border">
33
+ <strong class="text-sm text-rsb-muted">Type</strong>
34
+ <div>
35
+ <% if @role.superadmin? %>
36
+ <%= rsb_admin_badge(rsb_admin_t("roles.superadmin"), variant: :success) %>
37
+ <% elsif @role.built_in? %>
38
+ <%= rsb_admin_badge(rsb_admin_t("roles.built_in"), variant: :warning) %>
39
+ <% else %>
40
+ <%= rsb_admin_badge(rsb_admin_t("roles.custom"), variant: :success) %>
41
+ <% end %>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="grid grid-cols-[200px_1fr] gap-4 py-3">
46
+ <strong class="text-sm text-rsb-muted"><%= rsb_admin_t("roles.admin_users_count") %></strong>
47
+ <div><%= @role.admin_users.count %></div>
48
+ </div>
49
+ </div>
50
+
51
+ <div class="mt-6 bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
52
+ <h3 class="text-base font-semibold mb-4"><%= rsb_admin_t("roles.permissions") %></h3>
53
+
54
+ <% if @role.superadmin? %>
55
+ <p class="text-sm"><%= rsb_admin_t("roles.full_access") %></p>
56
+ <% elsif @role.permissions.empty? %>
57
+ <p class="text-rsb-muted text-sm"><%= rsb_admin_t("roles.no_permissions") %></p>
58
+ <% else %>
59
+ <div class="border border-rsb-border rounded-rsb overflow-hidden">
60
+ <% @role.permissions.each_with_index do |(resource_key, actions), idx| %>
61
+ <% next if resource_key == "*" %>
62
+ <% reg = @registry.find_resource_by_route_key(resource_key) %>
63
+ <% resource_label = reg ? reg.label : resource_key.titleize %>
64
+ <div class="px-4 py-3 flex items-center gap-4 flex-wrap <%= 'border-t border-rsb-border' if idx > 0 %>">
65
+ <strong class="min-w-[150px] text-sm"><%= resource_label %></strong>
66
+ <div class="flex gap-2 flex-wrap">
67
+ <% actions.each do |action| %>
68
+ <%= rsb_admin_badge(action, variant: :success) %>
69
+ <% end %>
70
+ </div>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+ <% end %>
75
+ </div>
76
+
77
+ <% unless @role.built_in? %>
78
+ <% if rsb_admin_can?("roles", "destroy") %>
79
+ <div class="mt-6 bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
80
+ <h3 class="text-base font-semibold text-rsb-danger mb-4"><%= rsb_admin_t("shared.danger_zone") %></h3>
81
+ <% if @role.admin_users.any? %>
82
+ <p class="text-sm text-rsb-muted mb-4"><%= rsb_admin_t("roles.reassign_warning", count: @role.admin_users.count) %></p>
83
+ <% end %>
84
+ <%= button_to rsb_admin_t("shared.delete") + " " + rsb_admin_t("roles.title").singularize,
85
+ rsb_admin.role_path(@role),
86
+ method: :delete,
87
+ data: { turbo_confirm: rsb_admin_t("roles.confirm_delete") },
88
+ class: "px-4 py-2 bg-rsb-danger text-white rounded-rsb text-sm font-medium hover:bg-red-700" %>
89
+ </div>
90
+ <% else %>
91
+ <div class="mt-6 bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
92
+ <h3 class="text-base font-semibold text-rsb-danger mb-4"><%= rsb_admin_t("shared.danger_zone") %></h3>
93
+ <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"
94
+ title="<%= rsb_admin_t("shared.no_access") %>">
95
+ <%= rsb_admin_t("shared.delete") %> <%= rsb_admin_t("roles.title").singularize %>
96
+ </span>
97
+ </div>
98
+ <% end %>
99
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <div class="flex justify-center items-center min-h-screen bg-rsb-bg">
2
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-8 w-full max-w-[400px] shadow-rsb">
3
+ <% if rsb_admin_logo_url.present? %>
4
+ <div class="flex justify-center mb-4">
5
+ <img src="<%= rsb_admin_logo_url %>" alt="<%= rsb_admin_app_name %>" class="h-10">
6
+ </div>
7
+ <% end %>
8
+ <% if rsb_admin_company_name.present? %>
9
+ <p class="text-sm text-rsb-muted text-center mb-2"><%= rsb_admin_company_name %></p>
10
+ <% end %>
11
+ <h1 class="text-2xl font-bold mb-6 text-center"><%= rsb_admin_t("sessions.sign_in") %></h1>
12
+ <form action="<%= rsb_admin.login_path %>" method="post">
13
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
14
+ <div class="mb-4">
15
+ <label class="block text-sm font-medium mb-1"><%= rsb_admin_t("sessions.email") %></label>
16
+ <input type="email" name="email" required
17
+ value="<%= @email %>"
18
+ 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">
19
+ </div>
20
+ <div class="mb-6">
21
+ <label class="block text-sm font-medium mb-1"><%= rsb_admin_t("sessions.password") %></label>
22
+ <input type="password" name="password" required
23
+ 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">
24
+ </div>
25
+ <button type="submit"
26
+ class="w-full px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
27
+ <%= rsb_admin_t("sessions.sign_in_button") %>
28
+ </button>
29
+ </form>
30
+ </div>
31
+ </div>
@@ -0,0 +1,39 @@
1
+ <div class="flex justify-center items-center min-h-screen bg-rsb-bg">
2
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg p-8 w-full max-w-[400px] shadow-rsb">
3
+ <% if RSB::Admin.configuration.logo_url.present? %>
4
+ <div class="flex justify-center mb-6">
5
+ <img src="<%= RSB::Admin.configuration.logo_url %>" alt="Logo" class="h-12" />
6
+ </div>
7
+ <% end %>
8
+
9
+ <h1 class="text-2xl font-bold text-center mb-6">Two-Factor Authentication</h1>
10
+ <p class="text-center mb-6 text-rsb-muted text-sm">Enter the 6-digit code from your authenticator app, or use a backup code.</p>
11
+
12
+ <% if flash[:alert] %>
13
+ <div class="mb-4 p-3 rounded-rsb bg-rsb-danger-bg border border-rsb-danger text-rsb-danger-text text-sm">
14
+ <%= flash[:alert] %>
15
+ </div>
16
+ <% end %>
17
+
18
+ <%= form_tag(verify_two_factor_login_path, method: :post) do %>
19
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %>
20
+
21
+ <div class="mb-4">
22
+ <label for="otp_code" class="block text-sm font-medium mb-1">Verification Code</label>
23
+ <input type="text" id="otp_code" name="otp_code"
24
+ 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
+ placeholder="6-digit code or backup code"
26
+ autocomplete="one-time-code" inputmode="numeric" autofocus required />
27
+ </div>
28
+
29
+ <button type="submit"
30
+ class="w-full px-4 py-2 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover">
31
+ Verify
32
+ </button>
33
+ <% end %>
34
+
35
+ <div class="mt-4 text-center">
36
+ <a href="<%= login_path %>" class="text-sm text-rsb-muted hover:underline">Cancel and sign in again</a>
37
+ </div>
38
+ </div>
39
+ </div>
@@ -0,0 +1,115 @@
1
+ <%
2
+ full_key = "#{category}.#{defn.key}"
3
+ field_name = "settings[values][#{defn.key}]"
4
+ disabled = state == :locked || state == :disabled_by_dependency
5
+ enum_values = defn.enum.respond_to?(:call) ? defn.enum.call : defn.enum
6
+
7
+ # CSS classes
8
+ wrapper_class = disabled ? "opacity-50" : ""
9
+ input_class = "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm bg-rsb-card text-rsb-text focus:outline-none focus:ring-1 focus:ring-rsb-primary focus:border-rsb-primary"
10
+ input_class += " cursor-not-allowed bg-rsb-muted-bg" if disabled
11
+ %>
12
+
13
+ <div class="<%= wrapper_class %>">
14
+ <%# Setting key in monospace %>
15
+ <div class="flex items-center gap-2 mb-1">
16
+ <code class="text-xs text-rsb-muted font-mono"><%= full_key %></code>
17
+ <% if state == :locked %>
18
+ <span class="inline-flex items-center gap-1 text-xs text-rsb-muted" title="<%= rsb_admin_t('settings.locked') %>">
19
+ <%= rsb_admin_icon("lock", size: 12) %>
20
+ <span><%= rsb_admin_t("settings.locked") %></span>
21
+ </span>
22
+ <% end %>
23
+ </div>
24
+
25
+ <%# Label %>
26
+ <label for="settings_values_<%= defn.key %>" class="block text-sm font-medium text-rsb-text mb-1">
27
+ <%= defn.label.presence || defn.key.to_s.titleize %>
28
+ </label>
29
+
30
+ <%# Input field by type %>
31
+ <% if enum_values.present? %>
32
+ <%# Enum -> select dropdown %>
33
+ <select name="<%= field_name %>"
34
+ id="settings_values_<%= defn.key %>"
35
+ class="<%= input_class %>"
36
+ <%= "disabled" if disabled %>>
37
+ <% enum_values.each do |opt| %>
38
+ <option value="<%= opt %>" <%= "selected" if value.to_s == opt.to_s %>><%= opt.to_s.titleize %></option>
39
+ <% end %>
40
+ </select>
41
+
42
+ <% elsif defn.type == :boolean %>
43
+ <%# Boolean -> toggle switch %>
44
+ <div class="flex items-center">
45
+ <%# Hidden field sends "false" when checkbox unchecked %>
46
+ <input type="hidden" name="<%= field_name %>" value="false" <%= "disabled" if disabled %>>
47
+ <label class="relative inline-flex items-center cursor-pointer <%= 'cursor-not-allowed' if disabled %>">
48
+ <input type="checkbox"
49
+ name="<%= field_name %>"
50
+ id="settings_values_<%= defn.key %>"
51
+ value="true"
52
+ class="sr-only peer"
53
+ <%= "checked" if ActiveModel::Type::Boolean.new.cast(value) %>
54
+ <%= "disabled" if disabled %>>
55
+ <div class="w-10 h-5 bg-rsb-border rounded-full peer
56
+ peer-checked:bg-rsb-primary peer-checked:after:translate-x-5
57
+ after:content-[''] after:absolute after:top-0.5 after:left-0.5
58
+ after:bg-white after:rounded-full after:h-4 after:w-4
59
+ after:transition-transform after:duration-200
60
+ transition-colors duration-200"></div>
61
+ <span class="ml-2 text-sm text-rsb-text">
62
+ <%= ActiveModel::Type::Boolean.new.cast(value) ? "Enabled" : "Disabled" %>
63
+ </span>
64
+ </label>
65
+ </div>
66
+
67
+ <% elsif defn.type == :integer || defn.type == :duration %>
68
+ <%# Integer / Duration -> number input %>
69
+ <div class="flex items-center gap-2">
70
+ <input type="number"
71
+ name="<%= field_name %>"
72
+ id="settings_values_<%= defn.key %>"
73
+ value="<%= value.is_a?(ActiveSupport::Duration) ? value.to_i : value %>"
74
+ class="<%= input_class %> max-w-xs"
75
+ <%= "disabled" if disabled %>>
76
+ <% if defn.type == :duration %>
77
+ <span class="text-sm text-rsb-muted">seconds</span>
78
+ <% end %>
79
+ </div>
80
+
81
+ <% elsif defn.type == :float %>
82
+ <%# Float -> number input with step %>
83
+ <input type="number"
84
+ name="<%= field_name %>"
85
+ id="settings_values_<%= defn.key %>"
86
+ value="<%= value %>"
87
+ step="any"
88
+ class="<%= input_class %> max-w-xs"
89
+ <%= "disabled" if disabled %>>
90
+
91
+ <% else %>
92
+ <%# String (default) -> text input %>
93
+ <input type="text"
94
+ name="<%= field_name %>"
95
+ id="settings_values_<%= defn.key %>"
96
+ value="<%= value %>"
97
+ class="<%= input_class %>"
98
+ <%= "disabled" if disabled %>>
99
+ <% end %>
100
+
101
+ <%# Description %>
102
+ <% if defn.description.present? %>
103
+ <p class="mt-1 text-xs text-rsb-muted"><%= defn.description %></p>
104
+ <% end %>
105
+
106
+ <%# depends_on disabled hint %>
107
+ <% if state == :disabled_by_dependency && defn.depends_on.present? %>
108
+ <%
109
+ parent_label = defn.depends_on.split(".", 2).last.to_s.titleize
110
+ %>
111
+ <p class="mt-1 text-xs text-amber-600 dark:text-amber-400">
112
+ Disabled because <strong><%= parent_label %></strong> is off.
113
+ </p>
114
+ <% end %>
115
+ </div>
@@ -0,0 +1,61 @@
1
+ <div class="flex justify-between items-center mb-6">
2
+ <h1 class="text-2xl font-bold"><%= rsb_admin_t("settings.title") %></h1>
3
+ </div>
4
+
5
+ <%# === Tab Bar === %>
6
+ <% if @categories.size > 1 %>
7
+ <div class="flex border-b border-rsb-border mb-6" role="tablist">
8
+ <% @categories.each do |cat| %>
9
+ <% active = cat == @active_tab %>
10
+ <a href="<%= rsb_admin.settings_path(tab: cat) %>"
11
+ role="tab"
12
+ aria-selected="<%= active %>"
13
+ class="px-4 py-2.5 text-sm font-medium border-b-2 transition-colors
14
+ <%= if active
15
+ 'border-rsb-primary text-rsb-primary'
16
+ else
17
+ 'border-transparent text-rsb-muted hover:text-rsb-text hover:border-rsb-border'
18
+ end %>">
19
+ <%= cat.titleize %>
20
+ </a>
21
+ <% end %>
22
+ </div>
23
+ <% end %>
24
+
25
+ <%# === Settings Form for Active Tab === %>
26
+ <% if @active_tab && @groups.any? %>
27
+ <%= form_with url: rsb_admin.settings_path, method: :patch, local: true do |f| %>
28
+ <%= hidden_field_tag "settings[category]", @active_tab %>
29
+ <%= hidden_field_tag "settings[tab]", @active_tab %>
30
+
31
+ <% @groups.each do |group_name, definitions| %>
32
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6 mb-4">
33
+ <h2 class="text-base font-semibold text-rsb-text mb-4 pb-2 border-b border-rsb-border">
34
+ <%= group_name %>
35
+ </h2>
36
+
37
+ <div class="space-y-5">
38
+ <% definitions.each do |defn| %>
39
+ <%= render partial: rsb_admin_partial("settings/field"), locals: {
40
+ defn: defn,
41
+ category: @active_tab,
42
+ value: @current_values[defn.key],
43
+ state: @field_states[defn.key],
44
+ f: f
45
+ } %>
46
+ <% end %>
47
+ </div>
48
+ </div>
49
+ <% end %>
50
+
51
+ <div class="flex justify-end mt-4">
52
+ <%= f.submit rsb_admin_t("settings.save", default: "Save Settings"),
53
+ class: "px-5 py-2.5 bg-rsb-primary text-rsb-primary-text rounded-rsb text-sm font-medium hover:bg-rsb-primary-hover cursor-pointer transition-colors" %>
54
+ </div>
55
+ <% end %>
56
+
57
+ <% elsif @categories.empty? %>
58
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
59
+ <p class="text-rsb-muted"><%= rsb_admin_t("dashboard.no_sections") %></p>
60
+ </div>
61
+ <% end %>
@@ -0,0 +1 @@
1
+ <%= rsb_admin_badge(text, variant: variant.to_sym) %>
@@ -0,0 +1,12 @@
1
+ <nav class="flex items-center gap-1 text-sm text-rsb-muted mb-4">
2
+ <% breadcrumbs.each_with_index do |item, i| %>
3
+ <% if i > 0 %>
4
+ <span class="mx-1"><%= rsb_admin_icon("chevron-right", size: 14) %></span>
5
+ <% end %>
6
+ <% if item.path && i < breadcrumbs.size - 1 %>
7
+ <a href="<%= item.path %>" class="hover:text-rsb-text transition-colors duration-[var(--rsb-admin-transition)]"><%= item.label %></a>
8
+ <% else %>
9
+ <span class="text-rsb-text font-medium"><%= item.label %></span>
10
+ <% end %>
11
+ <% end %>
12
+ </nav>