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,4 @@
1
+ <div class="text-center py-8">
2
+ <h3 class="text-base font-semibold text-rsb-text mb-1"><%= title %></h3>
3
+ <p class="text-sm text-rsb-muted"><%= message %></p>
4
+ </div>
@@ -0,0 +1,22 @@
1
+ <% flash.each do |type, message| %>
2
+ <%
3
+ type_str = type.to_s
4
+ config = case type_str
5
+ when "notice"
6
+ { bg: "bg-rsb-success-bg", text: "text-rsb-success-text", border: "border-green-200", icon: "check-circle" }
7
+ when "alert"
8
+ { bg: "bg-rsb-danger-bg", text: "text-rsb-danger-text", border: "border-red-200", icon: "alert-circle" }
9
+ when "warning"
10
+ { bg: "bg-rsb-warning-bg", text: "text-rsb-warning-text", border: "border-amber-200", icon: "alert-triangle" }
11
+ else
12
+ { bg: "bg-rsb-info-bg", text: "text-rsb-info-text", border: "border-blue-200", icon: "info" }
13
+ end
14
+ %>
15
+ <div class="<%= config[:bg] %> <%= config[:text] %> border <%= config[:border] %> px-4 py-3 rounded-rsb mb-4 text-sm flex items-center gap-3">
16
+ <span class="shrink-0"><%= rsb_admin_icon(config[:icon], size: 18) %></span>
17
+ <span class="flex-1"><%= message %></span>
18
+ <button type="button" onclick="this.parentElement.remove()" class="shrink-0 opacity-60 hover:opacity-100 cursor-pointer">
19
+ <%= rsb_admin_icon("x", size: 16) %>
20
+ </button>
21
+ </div>
22
+ <% end %>
@@ -0,0 +1,50 @@
1
+ <div class="text-sm text-rsb-muted">
2
+ <a href="<%= rsb_admin.profile_path %>" class="hover:text-rsb-text hover:underline"><%= current_admin_user.email %></a>
3
+ <% if current_admin_user.role %>
4
+ <span class="ml-1 text-xs text-rsb-muted">(<%= current_admin_user.role.name %>)</span>
5
+ <% end %>
6
+ </div>
7
+ <div class="flex items-center gap-3">
8
+ <% if rsb_available_locales.size > 1 %>
9
+ <div class="relative" id="rsb-locale-switcher">
10
+ <button type="button"
11
+ onclick="var m=document.getElementById('rsb-locale-menu');m.classList.toggle('hidden');event.stopPropagation()"
12
+ class="flex items-center gap-1 px-2 py-1 text-sm text-rsb-muted hover:text-rsb-text rounded-rsb hover:bg-rsb-bg transition-colors"
13
+ title="<%= rsb_admin_t('shared.switch_language') %>">
14
+ <%= rsb_admin_icon("globe", size: 16) %>
15
+ <span><%= rsb_current_locale.upcase %></span>
16
+ </button>
17
+ <div id="rsb-locale-menu" class="hidden absolute right-0 mt-1 w-44 bg-rsb-card border border-rsb-border rounded-rsb shadow-rsb-sm z-50">
18
+ <% rsb_available_locales.each do |loc| %>
19
+ <form action="/rsb/locale" method="post">
20
+ <input type="hidden" name="locale" value="<%= loc %>">
21
+ <input type="hidden" name="redirect_to" value="<%= request.fullpath %>">
22
+ <button type="submit"
23
+ class="w-full text-left px-3 py-2 text-sm transition-colors
24
+ <%= loc == rsb_current_locale ? 'font-medium text-rsb-primary bg-rsb-bg' : 'text-rsb-text hover:bg-rsb-bg' %>">
25
+ <%= rsb_locale_display_name(loc) %>
26
+ <% if loc == rsb_current_locale %>
27
+ <span class="float-right text-rsb-primary">&#10003;</span>
28
+ <% end %>
29
+ </button>
30
+ </form>
31
+ <% end %>
32
+ </div>
33
+ </div>
34
+ <script>
35
+ // Close locale dropdown when clicking outside
36
+ document.addEventListener('click', function(e) {
37
+ var menu = document.getElementById('rsb-locale-menu');
38
+ var switcher = document.getElementById('rsb-locale-switcher');
39
+ if (menu && switcher && !switcher.contains(e.target)) {
40
+ menu.classList.add('hidden');
41
+ }
42
+ });
43
+ </script>
44
+ <% end %>
45
+ <%= button_to rsb_admin.logout_path, method: :delete,
46
+ class: "flex items-center gap-1 text-sm text-rsb-muted hover:text-rsb-text" do %>
47
+ <%= rsb_admin_icon("log-out", size: 16) %>
48
+ <%= rsb_admin_t("shared.sign_out") %>
49
+ <% end %>
50
+ </div>
@@ -0,0 +1,21 @@
1
+ <% if page.actions.any? %>
2
+ <div class="flex gap-1 border-b border-rsb-border mb-6">
3
+ <% page.actions.each do |action| %>
4
+ <% action_path = rsb_admin_page_action_path(page.key, action[:key]) %>
5
+ <% is_active = request.path == action_path || (action[:key] == :index && request.path == rsb_admin_page_path(page.key)) %>
6
+ <% if action[:method] == :get %>
7
+ <a href="<%= action[:key] == :index ? rsb_admin_page_path(page.key) : action_path %>"
8
+ class="px-4 py-2 text-sm font-medium border-b-2 -mb-px <%= is_active ? 'border-rsb-primary text-rsb-primary' : 'border-transparent text-rsb-muted hover:text-rsb-text hover:border-rsb-border' %>">
9
+ <%= action[:label] %>
10
+ </a>
11
+ <% else %>
12
+ <%# POST/DELETE/PATCH actions render as button_to (rule #8) %>
13
+ <%= button_to action_path, method: action[:method],
14
+ data: action[:confirm] ? { turbo_confirm: action[:confirm] } : {},
15
+ class: "px-4 py-2 text-sm font-medium border-b-2 -mb-px border-transparent text-rsb-muted hover:text-rsb-text" do %>
16
+ <%= action[:label] %>
17
+ <% end %>
18
+ <% end %>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
@@ -0,0 +1,99 @@
1
+ <div class="px-4 pb-4 text-lg font-bold text-white border-b border-white/10 mb-2">
2
+ <% if rsb_admin_logo_url.present? %>
3
+ <img src="<%= rsb_admin_logo_url %>" alt="<%= rsb_admin_app_name %>" class="h-8">
4
+ <% else %>
5
+ <%= rsb_admin_app_name %>
6
+ <% end %>
7
+ </div>
8
+ <nav>
9
+ <% if rsb_admin_can?("dashboard", "index") %>
10
+ <a href="<%= rsb_admin.dashboard_path %>"
11
+ class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] <%= request.path == rsb_admin.dashboard_path ? 'border-rsb-sidebar-active text-white bg-white/[0.08]' : 'border-transparent text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
12
+ <%= rsb_admin_icon("layout-dashboard", size: 16) %>
13
+ <%= rsb_admin_t("shared.dashboard") %>
14
+ </a>
15
+ <% else %>
16
+ <span class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] border-transparent text-white/30 cursor-not-allowed"
17
+ title="<%= rsb_admin_t("shared.no_access") %>">
18
+ <%= rsb_admin_icon("layout-dashboard", size: 16) %>
19
+ <%= rsb_admin_t("shared.dashboard") %>
20
+ </span>
21
+ <% end %>
22
+
23
+ <% RSB::Admin.registry.categories.each do |name, category| %>
24
+ <%
25
+ cat_has_permitted = category.resources.any? { |r| rsb_admin_can?(r.route_key, "index") } ||
26
+ category.pages.any? { |p| rsb_admin_can?(p.key.to_s, "index") }
27
+ %>
28
+ <% if cat_has_permitted %>
29
+ <div class="px-4 pt-3 pb-1 text-[0.7rem] uppercase tracking-wider text-white/40 mt-2"><%= name %></div>
30
+ <% end %>
31
+ <% category.resources.each do |resource| %>
32
+ <% path = rsb_admin_resource_path(resource.route_key) %>
33
+ <% if rsb_admin_can?(resource.route_key, "index") %>
34
+ <a href="<%= path %>"
35
+ class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] <%= request.path.start_with?(path) ? 'border-rsb-sidebar-active text-white bg-white/[0.08]' : 'border-transparent text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
36
+ <% if resource.icon %>
37
+ <%= rsb_admin_icon(resource.icon, size: 16) %>
38
+ <% end %>
39
+ <%= resource.label %>
40
+ </a>
41
+ <% elsif cat_has_permitted %>
42
+ <span class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] border-transparent text-white/30 cursor-not-allowed"
43
+ title="<%= rsb_admin_t("shared.no_access") %>">
44
+ <% if resource.icon %>
45
+ <%= rsb_admin_icon(resource.icon, size: 16) %>
46
+ <% end %>
47
+ <%= resource.label %>
48
+ </span>
49
+ <% end %>
50
+ <% end %>
51
+ <% category.pages.each do |page| %>
52
+ <% path = rsb_admin_page_path(page.key) %>
53
+ <% if rsb_admin_can?(page.key.to_s, "index") %>
54
+ <a href="<%= path %>"
55
+ class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] <%= request.path.start_with?(path) ? 'border-rsb-sidebar-active text-white bg-white/[0.08]' : 'border-transparent text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
56
+ <% if page.icon %>
57
+ <%= rsb_admin_icon(page.icon, size: 16) %>
58
+ <% end %>
59
+ <%= page.label %>
60
+ </a>
61
+ <% elsif cat_has_permitted %>
62
+ <span class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] border-transparent text-white/30 cursor-not-allowed"
63
+ title="<%= rsb_admin_t("shared.no_access") %>">
64
+ <% if page.icon %>
65
+ <%= rsb_admin_icon(page.icon, size: 16) %>
66
+ <% end %>
67
+ <%= page.label %>
68
+ </span>
69
+ <% end %>
70
+ <% end %>
71
+ <% end %>
72
+
73
+ <%
74
+ system_items = {
75
+ "admin_users" => { path: rsb_admin.admin_users_path, icon: "users-cog", label: rsb_admin_t("admin_users.title") },
76
+ "roles" => { path: rsb_admin.roles_path, icon: "key", label: rsb_admin_t("roles.title") },
77
+ "settings" => { path: rsb_admin.settings_path, icon: "settings", label: rsb_admin_t("settings.title") }
78
+ }
79
+ system_has_permitted = system_items.any? { |key, _| rsb_admin_can?(key, "index") }
80
+ %>
81
+ <% if system_has_permitted %>
82
+ <div class="px-4 pt-3 pb-1 text-[0.7rem] uppercase tracking-wider text-white/40 mt-2"><%= rsb_admin_t("shared.system") %></div>
83
+ <% end %>
84
+ <% system_items.each do |key, item| %>
85
+ <% if rsb_admin_can?(key, "index") %>
86
+ <a href="<%= item[:path] %>"
87
+ class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] <%= request.path.start_with?(item[:path]) ? 'border-rsb-sidebar-active text-white bg-white/[0.08]' : 'border-transparent text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
88
+ <%= rsb_admin_icon(item[:icon], size: 16) %>
89
+ <%= item[:label] %>
90
+ </a>
91
+ <% elsif system_has_permitted %>
92
+ <span class="flex items-center gap-2 px-4 py-2 text-sm border-l-[3px] border-transparent text-white/30 cursor-not-allowed"
93
+ title="<%= rsb_admin_t("shared.no_access") %>">
94
+ <%= rsb_admin_icon(item[:icon], size: 16) %>
95
+ <%= item[:label] %>
96
+ </span>
97
+ <% end %>
98
+ <% end %>
99
+ </nav>
@@ -0,0 +1,38 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= RSB::Settings.get("admin.app_name") || RSB::Admin.configuration.app_name %> — Disabled</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <style>
7
+ body {
8
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
9
+ display: flex;
10
+ justify-content: center;
11
+ align-items: center;
12
+ min-height: 100vh;
13
+ margin: 0;
14
+ background: #f8f9fa;
15
+ color: #333;
16
+ }
17
+ .container {
18
+ text-align: center;
19
+ padding: 2rem;
20
+ max-width: 480px;
21
+ }
22
+ h1 {
23
+ font-size: 1.5rem;
24
+ margin-bottom: 0.5rem;
25
+ }
26
+ p {
27
+ color: #666;
28
+ line-height: 1.6;
29
+ }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <div class="container">
34
+ <h1><%= I18n.t("rsb.admin.shared.panel_disabled") %></h1>
35
+ <p><%= I18n.t("rsb.admin.shared.panel_disabled_message") %></p>
36
+ </div>
37
+ </body>
38
+ </html>
@@ -0,0 +1,6 @@
1
+ <div class="mb-4 flex items-center gap-2">
2
+ <%= form.check_box field.key, class: "rounded border-rsb-border text-rsb-primary focus:ring-rsb-primary" %>
3
+ <label class="text-sm font-medium">
4
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
5
+ </label>
6
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.datetime_local_field field.key, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.email_field field.key, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1 @@
1
+ <%= form.hidden_field field.key %>
@@ -0,0 +1,11 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ </label>
5
+ <% value = form.object.send(field.key) %>
6
+ <%= form.text_area field.key, value: (value.is_a?(Hash) || value.is_a?(Array)) ? JSON.pretty_generate(value) : value.to_s,
7
+ rows: 6, class: "w-full px-3 py-2 border border-rsb-border rounded-rsb text-sm font-mono focus:outline-none focus:border-rsb-primary focus:ring-2 focus:ring-rsb-primary/10" %>
8
+ <% if field.hint %>
9
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
10
+ <% end %>
11
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.number_field field.key, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.password_field field.key, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,12 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <% opts = field.options.is_a?(Proc) ? field.options.call : field.options %>
7
+ <%= form.select field.key, opts&.map { |o| [o.to_s.titleize, o] }, { include_blank: true },
8
+ 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" %>
9
+ <% if field.hint %>
10
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
11
+ <% end %>
12
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.text_field field.key, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,10 @@
1
+ <div class="mb-4">
2
+ <label class="block text-sm font-medium mb-1">
3
+ <%= rsb_admin_field_label(field, resource_key: resource_key) %>
4
+ <% if field.required %><span class="text-rsb-danger text-xs ml-1">(<%= rsb_admin_t("shared.required") %>)</span><% end %>
5
+ </label>
6
+ <%= form.text_area field.key, rows: 4, 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" %>
7
+ <% if field.hint %>
8
+ <p class="text-xs text-rsb-muted mt-1"><%= field.hint %></p>
9
+ <% end %>
10
+ </div>
@@ -0,0 +1,22 @@
1
+ <div class="flex items-center justify-center min-h-[60vh]">
2
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-8 max-w-md w-full text-center">
3
+ <div class="mb-4 text-rsb-muted">
4
+ <%= rsb_admin_icon("lock", size: 48) %>
5
+ </div>
6
+ <h1 class="text-2xl font-bold mb-2"><%= rsb_admin_t("shared.access_denied") %></h1>
7
+ <p class="text-rsb-muted mb-6"><%= rsb_admin_t("shared.access_denied_message") %></p>
8
+ <div class="flex flex-col gap-3 items-center">
9
+ <% if current_admin_user.can?("dashboard", "index") %>
10
+ <a href="<%= rsb_admin.dashboard_path %>"
11
+ 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">
12
+ <%= rsb_admin_t("shared.go_to_dashboard") %>
13
+ </a>
14
+ <% end %>
15
+ <a href="<%= rsb_admin.logout_path %>"
16
+ class="text-sm text-rsb-muted hover:text-rsb-text"
17
+ data-turbo-method="delete">
18
+ <%= rsb_admin_t("shared.sign_out_and_try") %>
19
+ </a>
20
+ </div>
21
+ </div>
22
+ </div>
@@ -0,0 +1,77 @@
1
+ <div class="flex items-center gap-3">
2
+ <div class="text-sm text-rsb-muted">
3
+ <a href="<%= rsb_admin.profile_path %>" class="font-medium text-rsb-text hover:underline transition-colors"><%= current_admin_user.email %></a>
4
+ <% if current_admin_user.role %>
5
+ <span class="ml-1 text-xs">(<%= current_admin_user.role.name %>)</span>
6
+ <% end %>
7
+ </div>
8
+ </div>
9
+ <div class="flex items-center gap-3">
10
+ <% if rsb_available_locales.size > 1 %>
11
+ <div class="relative" id="rsb-locale-switcher">
12
+ <button type="button"
13
+ onclick="var m=document.getElementById('rsb-locale-menu');m.classList.toggle('hidden');event.stopPropagation()"
14
+ class="flex items-center gap-1 px-2 py-1 text-sm text-rsb-muted hover:text-rsb-text rounded-rsb hover:bg-rsb-bg transition-colors"
15
+ title="<%= rsb_admin_t('shared.switch_language') %>">
16
+ <%= rsb_admin_icon("globe", size: 16) %>
17
+ <span><%= rsb_current_locale.upcase %></span>
18
+ </button>
19
+ <div id="rsb-locale-menu" class="hidden absolute right-0 mt-1 w-44 bg-rsb-card border border-rsb-border rounded-rsb shadow-rsb-sm z-50">
20
+ <% rsb_available_locales.each do |loc| %>
21
+ <form action="/rsb/locale" method="post">
22
+ <input type="hidden" name="locale" value="<%= loc %>">
23
+ <input type="hidden" name="redirect_to" value="<%= request.fullpath %>">
24
+ <button type="submit"
25
+ class="w-full text-left px-3 py-2 text-sm transition-colors
26
+ <%= loc == rsb_current_locale ? 'font-medium text-rsb-primary bg-rsb-bg' : 'text-rsb-text hover:bg-rsb-bg' %>">
27
+ <%= rsb_locale_display_name(loc) %>
28
+ <% if loc == rsb_current_locale %>
29
+ <span class="float-right text-rsb-primary">&#10003;</span>
30
+ <% end %>
31
+ </button>
32
+ </form>
33
+ <% end %>
34
+ </div>
35
+ </div>
36
+ <script>
37
+ // Close locale dropdown when clicking outside
38
+ document.addEventListener('click', function(e) {
39
+ var menu = document.getElementById('rsb-locale-menu');
40
+ var switcher = document.getElementById('rsb-locale-switcher');
41
+ if (menu && switcher && !switcher.contains(e.target)) {
42
+ menu.classList.add('hidden');
43
+ }
44
+ });
45
+ </script>
46
+ <% end %>
47
+
48
+ <%# Dark/light mode toggle (rule #17) %>
49
+ <button onclick="rsbToggleMode()" type="button"
50
+ class="p-2 rounded-rsb text-rsb-muted hover:text-rsb-text hover:bg-rsb-bg transition-colors"
51
+ title="Toggle dark/light mode">
52
+ <span class="rsb-light-icon"><%= rsb_admin_icon("moon", size: 18) %></span>
53
+ <span class="rsb-dark-icon" style="display:none"><%= rsb_admin_icon("sun", size: 18) %></span>
54
+ </button>
55
+
56
+ <%= button_to rsb_admin.logout_path, method: :delete,
57
+ class: "flex items-center gap-1 text-sm text-rsb-muted hover:text-rsb-text transition-colors" do %>
58
+ <%= rsb_admin_icon("log-out", size: 16) %>
59
+ <%= rsb_admin_t("shared.sign_out") %>
60
+ <% end %>
61
+ </div>
62
+
63
+ <script>
64
+ // Toggle icon visibility based on current mode
65
+ (function() {
66
+ function updateIcons() {
67
+ var mode = document.documentElement.getAttribute('data-rsb-mode') || 'light';
68
+ var lightIcons = document.querySelectorAll('.rsb-light-icon');
69
+ var darkIcons = document.querySelectorAll('.rsb-dark-icon');
70
+ lightIcons.forEach(function(el) { el.style.display = mode === 'dark' ? 'none' : 'inline'; });
71
+ darkIcons.forEach(function(el) { el.style.display = mode === 'dark' ? 'inline' : 'none'; });
72
+ }
73
+ updateIcons();
74
+ var observer = new MutationObserver(updateIcons);
75
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-rsb-mode'] });
76
+ })();
77
+ </script>
@@ -0,0 +1,135 @@
1
+ <div class="px-4 pb-4 text-lg font-bold text-white border-b border-white/10 mb-2 flex items-center gap-2">
2
+ <% if rsb_admin_logo_url.present? %>
3
+ <img src="<%= rsb_admin_logo_url %>" alt="<%= rsb_admin_app_name %>" class="h-8">
4
+ <% else %>
5
+ <%= rsb_admin_icon("layout-dashboard", size: 20) %>
6
+ <%= rsb_admin_app_name %>
7
+ <% end %>
8
+ </div>
9
+ <nav>
10
+ <% if rsb_admin_can?("dashboard", "index") %>
11
+ <a href="<%= rsb_admin.dashboard_path %>"
12
+ class="flex items-center gap-2 px-4 py-2.5 text-sm transition-all duration-[var(--rsb-admin-transition)] <%= request.path == rsb_admin.dashboard_path ? 'text-white bg-white/[0.08]' : 'text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
13
+ <%= rsb_admin_icon("home", size: 16) %>
14
+ <%= rsb_admin_t("shared.dashboard") %>
15
+ </a>
16
+ <% else %>
17
+ <span class="flex items-center gap-2 px-4 py-2.5 text-sm text-white/30 cursor-not-allowed"
18
+ title="<%= rsb_admin_t("shared.no_access") %>">
19
+ <%= rsb_admin_icon("home", size: 16) %>
20
+ <%= rsb_admin_t("shared.dashboard") %>
21
+ </span>
22
+ <% end %>
23
+
24
+ <% RSB::Admin.registry.categories.each_with_index do |(name, category), idx| %>
25
+ <%
26
+ cat_has_permitted = category.resources.any? { |r| rsb_admin_can?(r.route_key, "index") } ||
27
+ category.pages.any? { |p| rsb_admin_can?(p.key.to_s, "index") }
28
+ %>
29
+ <% if cat_has_permitted %>
30
+ <% section_id = "rsb-cat-#{idx}" %>
31
+ <% cat_paths = category.resources.map { |r| rsb_admin_resource_path(r.route_key) } + category.pages.map { |p| rsb_admin_page_path(p.key) } %>
32
+ <% is_active = cat_paths.any? { |p| request.path.start_with?(p) } %>
33
+ <div class="mt-3">
34
+ <button type="button" onclick="rsbToggleSection('<%= section_id %>')"
35
+ class="w-full flex items-center justify-between px-4 py-2 text-[0.7rem] uppercase tracking-wider text-white/40 hover:text-white/60 transition-colors cursor-pointer">
36
+ <span class="flex items-center gap-1.5">
37
+ <span><%= name %></span>
38
+ <span class="text-[0.6rem] text-white/20"><%= category.resources.size + category.pages.size %></span>
39
+ </span>
40
+ <span id="<%= section_id %>-chevron" class="transition-transform duration-200 <%= is_active ? 'rotate-90' : '' %>">
41
+ <%= rsb_admin_icon("chevron-right", size: 14) %>
42
+ </span>
43
+ </button>
44
+ <div id="<%= section_id %>" class="<%= is_active ? '' : 'hidden' %>">
45
+ <% category.resources.each do |resource| %>
46
+ <% path = rsb_admin_resource_path(resource.route_key) %>
47
+ <% if rsb_admin_can?(resource.route_key, "index") %>
48
+ <a href="<%= path %>"
49
+ class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm transition-all duration-[var(--rsb-admin-transition)] group <%= request.path.start_with?(path) ? 'text-white bg-white/[0.08]' : 'text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
50
+ <% if resource.icon %>
51
+ <span class="opacity-60 group-hover:opacity-100 transition-opacity"><%= rsb_admin_icon(resource.icon, size: 16) %></span>
52
+ <% else %>
53
+ <span class="opacity-40 group-hover:opacity-70 transition-opacity"><%= rsb_admin_icon("chevron-right", size: 12) %></span>
54
+ <% end %>
55
+ <span><%= resource.label %></span>
56
+ </a>
57
+ <% else %>
58
+ <span class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm text-white/30 cursor-not-allowed"
59
+ title="<%= rsb_admin_t("shared.no_access") %>">
60
+ <% if resource.icon %>
61
+ <span class="opacity-30"><%= rsb_admin_icon(resource.icon, size: 16) %></span>
62
+ <% else %>
63
+ <span class="opacity-20"><%= rsb_admin_icon("chevron-right", size: 12) %></span>
64
+ <% end %>
65
+ <span><%= resource.label %></span>
66
+ </span>
67
+ <% end %>
68
+ <% end %>
69
+ <% category.pages.each do |page| %>
70
+ <% path = rsb_admin_page_path(page.key) %>
71
+ <% if rsb_admin_can?(page.key.to_s, "index") %>
72
+ <a href="<%= path %>"
73
+ class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm transition-all duration-[var(--rsb-admin-transition)] group <%= request.path.start_with?(path) ? 'text-white bg-white/[0.08]' : 'text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
74
+ <% if page.icon %>
75
+ <span class="opacity-60 group-hover:opacity-100 transition-opacity"><%= rsb_admin_icon(page.icon, size: 16) %></span>
76
+ <% else %>
77
+ <span class="opacity-40 group-hover:opacity-70 transition-opacity"><%= rsb_admin_icon("chevron-right", size: 12) %></span>
78
+ <% end %>
79
+ <span><%= page.label %></span>
80
+ </a>
81
+ <% else %>
82
+ <span class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm text-white/30 cursor-not-allowed"
83
+ title="<%= rsb_admin_t("shared.no_access") %>">
84
+ <% if page.icon %>
85
+ <span class="opacity-30"><%= rsb_admin_icon(page.icon, size: 16) %></span>
86
+ <% else %>
87
+ <span class="opacity-20"><%= rsb_admin_icon("chevron-right", size: 12) %></span>
88
+ <% end %>
89
+ <span><%= page.label %></span>
90
+ </span>
91
+ <% end %>
92
+ <% end %>
93
+ </div>
94
+ </div>
95
+ <% end %>
96
+ <% end %>
97
+
98
+ <%
99
+ system_items = {
100
+ "admin_users" => { path: rsb_admin.admin_users_path, icon: "users-cog", label: rsb_admin_t("admin_users.title") },
101
+ "roles" => { path: rsb_admin.roles_path, icon: "key", label: rsb_admin_t("roles.title") },
102
+ "settings" => { path: rsb_admin.settings_path, icon: "settings", label: rsb_admin_t("settings.title") }
103
+ }
104
+ system_has_permitted = system_items.any? { |key, _| rsb_admin_can?(key, "index") }
105
+ %>
106
+ <% if system_has_permitted %>
107
+ <% system_active = system_items.any? { |key, item| rsb_admin_can?(key, "index") && request.path.start_with?(item[:path]) } %>
108
+ <div class="mt-3">
109
+ <button type="button" onclick="rsbToggleSection('rsb-system')"
110
+ class="w-full flex items-center justify-between px-4 py-2 text-[0.7rem] uppercase tracking-wider text-white/40 hover:text-white/60 transition-colors cursor-pointer">
111
+ <span><%= rsb_admin_t("shared.system") %></span>
112
+ <span id="rsb-system-chevron" class="transition-transform duration-200 <%= system_active ? 'rotate-90' : '' %>">
113
+ <%= rsb_admin_icon("chevron-right", size: 14) %>
114
+ </span>
115
+ </button>
116
+ <div id="rsb-system" class="<%= system_active ? '' : 'hidden' %>">
117
+ <% system_items.each do |key, item| %>
118
+ <% if rsb_admin_can?(key, "index") %>
119
+ <a href="<%= item[:path] %>"
120
+ class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm transition-all duration-[var(--rsb-admin-transition)] group <%= request.path.start_with?(item[:path]) ? 'text-white bg-white/[0.08]' : 'text-rsb-sidebar-text hover:bg-rsb-sidebar-hover hover:text-white' %>">
121
+ <span class="opacity-60 group-hover:opacity-100 transition-opacity"><%= rsb_admin_icon(item[:icon], size: 16) %></span>
122
+ <span><%= item[:label] %></span>
123
+ </a>
124
+ <% else %>
125
+ <span class="flex items-center gap-2 pl-6 pr-4 py-2 text-sm text-white/30 cursor-not-allowed"
126
+ title="<%= rsb_admin_t("shared.no_access") %>">
127
+ <span class="opacity-30"><%= rsb_admin_icon(item[:icon], size: 16) %></span>
128
+ <span><%= item[:label] %></span>
129
+ </span>
130
+ <% end %>
131
+ <% end %>
132
+ </div>
133
+ </div>
134
+ <% end %>
135
+ </nav>
@@ -0,0 +1,48 @@
1
+ <div class="max-w-lg mx-auto">
2
+ <div class="bg-rsb-card border border-rsb-border rounded-rsb-lg shadow-rsb-sm p-6">
3
+ <h2 class="text-xl font-bold mb-4">Backup Codes</h2>
4
+
5
+ <div class="mb-4 p-3 rounded-rsb bg-rsb-warning-bg border border-rsb-warning text-rsb-warning-text text-sm">
6
+ <strong>Save these codes!</strong> Each code can only be used once. Store them in a safe place.
7
+ You will not be able to see these codes again.
8
+ </div>
9
+
10
+ <div class="grid grid-cols-2 gap-2 mb-6" id="backup-codes">
11
+ <% @backup_codes.each do |code| %>
12
+ <code class="block p-2 text-center bg-rsb-bg border border-rsb-border rounded-rsb font-mono text-sm select-all">
13
+ <%= code %>
14
+ </code>
15
+ <% end %>
16
+ </div>
17
+
18
+ <div class="flex gap-2 mb-4">
19
+ <button onclick="copyBackupCodes()" type="button" id="copy-button"
20
+ class="flex-1 py-2 px-4 border border-rsb-border rounded-rsb hover:bg-rsb-bg transition-colors text-sm">
21
+ Copy All Codes
22
+ </button>
23
+ </div>
24
+
25
+ <a href="<%= rsb_admin.profile_path %>"
26
+ class="block text-center py-2 px-4 bg-rsb-primary text-rsb-primary-text rounded-rsb hover:bg-rsb-primary-hover">
27
+ Done — Go to Profile
28
+ </a>
29
+ </div>
30
+ </div>
31
+
32
+ <script>
33
+ function copyBackupCodes() {
34
+ var codes = [];
35
+ document.querySelectorAll('#backup-codes code').forEach(function(el) {
36
+ codes.push(el.textContent.trim());
37
+ });
38
+ var button = document.getElementById('copy-button');
39
+ var originalText = button.textContent;
40
+
41
+ navigator.clipboard.writeText(codes.join('\n')).then(function() {
42
+ button.textContent = 'Copied!';
43
+ setTimeout(function() {
44
+ button.textContent = originalText;
45
+ }, 2000);
46
+ });
47
+ }
48
+ </script>