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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper methods for internationalization (i18n) in the admin panel.
6
+ #
7
+ # This helper provides scoped translation methods and label resolution
8
+ # with fallback chains for columns, filters, and form fields.
9
+ #
10
+ # The helper is automatically included in all RSB::Admin controllers
11
+ # via the AdminController base class.
12
+ #
13
+ # @example In a view
14
+ # <%= rsb_admin_t("shared.save") %>
15
+ # # => "Save"
16
+ #
17
+ # @example Resolving column labels with fallback
18
+ # <%= rsb_admin_column_label(column, resource_key: "users") %>
19
+ # # Tries: rsb.admin.resources.users.columns.email
20
+ # # Falls back to: rsb.admin.columns.email
21
+ # # Falls back to: column.label
22
+ module I18nHelper
23
+ # Shorthand for admin-scoped i18n lookups.
24
+ #
25
+ # Prefixes the given key with `rsb.admin.` and delegates to Rails' `t()` helper.
26
+ # This keeps view code concise and DRY.
27
+ #
28
+ # @param key [String, Symbol] the i18n key relative to `rsb.admin`
29
+ # @param options [Hash] options to pass to `I18n.t` (e.g., interpolation values)
30
+ #
31
+ # @return [String] the translated string
32
+ #
33
+ # @example Basic usage
34
+ # rsb_admin_t("shared.save")
35
+ # # => "Save"
36
+ #
37
+ # @example With interpolation
38
+ # rsb_admin_t("shared.new", resource: "User")
39
+ # # => "New User"
40
+ #
41
+ # @example With count for pluralization
42
+ # rsb_admin_t("shared.showing", from: 1, to: 25, total: 100)
43
+ # # => "Showing 1-25 of 100"
44
+ def rsb_admin_t(key, **options)
45
+ I18n.t("rsb.admin.#{key}", **options)
46
+ end
47
+
48
+ # Resolve a column label through i18n with fallbacks.
49
+ #
50
+ # The label resolution follows a 4-level fallback chain:
51
+ # 1. Per-resource i18n key: `rsb.admin.resources.#{resource_key}.columns.#{column.key}` (if resource_key provided)
52
+ # 2. Global column i18n key: `rsb.admin.columns.#{column.key}`
53
+ # 3. DSL-provided label from ColumnDefinition (column.label)
54
+ # 4. Humanized key (already the default for column.label)
55
+ #
56
+ # This allows per-resource overrides (e.g., "Identity Email" for identities),
57
+ # global column names (e.g., "Email" for all resources), and DSL-level defaults.
58
+ #
59
+ # @param column [ColumnDefinition] the column definition object
60
+ # @param resource_key [String, Symbol, nil] optional resource key for per-resource translations
61
+ #
62
+ # @return [String] the resolved label
63
+ #
64
+ # @example With global i18n
65
+ # col = ColumnDefinition.build(:email, label: "Fallback")
66
+ # rsb_admin_column_label(col)
67
+ # # => "Email" (from rsb.admin.columns.email)
68
+ #
69
+ # @example With per-resource override
70
+ # rsb_admin_column_label(col, resource_key: "identities")
71
+ # # => "Identity Email" (from rsb.admin.resources.identities.columns.email)
72
+ #
73
+ # @example Falls back to DSL label
74
+ # col = ColumnDefinition.build(:custom_field, label: "My Custom Field")
75
+ # rsb_admin_column_label(col)
76
+ # # => "My Custom Field" (DSL label, no i18n key exists)
77
+ def rsb_admin_column_label(column, resource_key: nil)
78
+ if resource_key
79
+ result = I18n.t("rsb.admin.resources.#{resource_key}.columns.#{column.key}", default: nil)
80
+ return result if result
81
+ end
82
+
83
+ global = I18n.t("rsb.admin.columns.#{column.key}", default: nil)
84
+ return global if global
85
+
86
+ column.label
87
+ end
88
+
89
+ # Resolve a filter label through i18n with fallbacks.
90
+ #
91
+ # The label resolution follows a fallback chain:
92
+ # 1. Per-resource i18n key: `rsb.admin.resources.#{resource_key}.filters.#{filter.key}` (if resource_key provided)
93
+ # 2. DSL-provided label from FilterDefinition (filter.label)
94
+ #
95
+ # @param filter [FilterDefinition] the filter definition object
96
+ # @param resource_key [String, Symbol, nil] optional resource key for per-resource translations
97
+ #
98
+ # @return [String] the resolved label
99
+ #
100
+ # @example With per-resource i18n
101
+ # filter = FilterDefinition.build(:status, label: "Fallback")
102
+ # rsb_admin_filter_label(filter, resource_key: "users")
103
+ # # => "User Status" (from rsb.admin.resources.users.filters.status)
104
+ #
105
+ # @example Falls back to DSL label
106
+ # filter = FilterDefinition.build(:custom, label: "Custom Filter")
107
+ # rsb_admin_filter_label(filter)
108
+ # # => "Custom Filter" (DSL label, no i18n key exists)
109
+ def rsb_admin_filter_label(filter, resource_key: nil)
110
+ if resource_key
111
+ result = I18n.t("rsb.admin.resources.#{resource_key}.filters.#{filter.key}", default: nil)
112
+ return result if result
113
+ end
114
+
115
+ filter.label
116
+ end
117
+
118
+ # Resolve a form field label through i18n with fallbacks.
119
+ #
120
+ # The label resolution follows a fallback chain:
121
+ # 1. Per-resource i18n key: `rsb.admin.resources.#{resource_key}.fields.#{field.key}` (if resource_key provided)
122
+ # 2. DSL-provided label from FormFieldDefinition (field.label)
123
+ #
124
+ # @param field [FormFieldDefinition] the form field definition object
125
+ # @param resource_key [String, Symbol, nil] optional resource key for per-resource translations
126
+ #
127
+ # @return [String] the resolved label
128
+ #
129
+ # @example With per-resource i18n
130
+ # field = FormFieldDefinition.build(:email, label: "Fallback")
131
+ # rsb_admin_field_label(field, resource_key: "users")
132
+ # # => "User Email Address" (from rsb.admin.resources.users.fields.email)
133
+ #
134
+ # @example Falls back to DSL label
135
+ # field = FormFieldDefinition.build(:custom, label: "Custom Field")
136
+ # rsb_admin_field_label(field)
137
+ # # => "Custom Field" (DSL label, no i18n key exists)
138
+ def rsb_admin_field_label(field, resource_key: nil)
139
+ if resource_key
140
+ result = I18n.t("rsb.admin.resources.#{resource_key}.fields.#{field.key}", default: nil)
141
+ return result if result
142
+ end
143
+
144
+ field.label
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper methods for rendering Lucide icons in admin panel views.
6
+ #
7
+ # This helper is automatically included in all RSB::Admin controllers
8
+ # via the AdminController base class.
9
+ #
10
+ # @example In a view
11
+ # <%= rsb_admin_icon("users") %>
12
+ # <%= rsb_admin_icon("home", size: 24, css_class: "text-blue-500") %>
13
+ module IconsHelper
14
+ # Render a Lucide icon SVG with optional CSS class.
15
+ #
16
+ # Wraps {RSB::Admin::Icons.render} and optionally injects a CSS class
17
+ # into the SVG tag. The class value is HTML-escaped to prevent XSS.
18
+ # If the icon is not found, returns an empty string (no error - rule #9).
19
+ #
20
+ # @param name [String, Symbol] The icon name (e.g., "users", :home)
21
+ # @param size [Integer] The width and height of the icon in pixels
22
+ # @param css_class [String, nil] Optional CSS class to add to the SVG tag
23
+ #
24
+ # @return [ActiveSupport::SafeBuffer] HTML-safe SVG string with optional class, or empty string if icon not found
25
+ #
26
+ # @example Basic usage
27
+ # rsb_admin_icon("users")
28
+ # # => '<svg xmlns="..." width="18" height="18" ...>...</svg>'
29
+ #
30
+ # @example With custom size
31
+ # rsb_admin_icon("home", size: 24)
32
+ # # => '<svg xmlns="..." width="24" height="24" ...>...</svg>'
33
+ #
34
+ # @example With CSS class
35
+ # rsb_admin_icon("settings", css_class: "icon-lg text-gray-600")
36
+ # # => '<svg class="icon-lg text-gray-600" xmlns="..." ...>...</svg>'
37
+ #
38
+ # @example Unknown icon returns empty string
39
+ # rsb_admin_icon("nonexistent")
40
+ # # => ""
41
+ #
42
+ # @example XSS-safe (class is HTML-escaped)
43
+ # rsb_admin_icon("users", css_class: '"><script>alert("xss")</script>')
44
+ # # => '<svg class="&quot;&gt;&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;" ...>...</svg>'
45
+ def rsb_admin_icon(name, size: 18, css_class: nil)
46
+ svg = RSB::Admin::Icons.render(name, size: size)
47
+ if css_class && svg.present?
48
+ svg.sub('<svg ', "<svg class=\"#{ERB::Util.html_escape(css_class)}\" ").html_safe
49
+ else
50
+ svg
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper methods for generating sortable table links and preserving filter state.
6
+ #
7
+ # This helper provides utilities for building sort links that cycle through
8
+ # sort states (asc → desc → none) and preserve existing filter parameters
9
+ # in the URL. The helper is automatically included in all RSB::Admin
10
+ # controllers via the AdminController base class.
11
+ #
12
+ # @example In a view
13
+ # <% columns.each do |col| %>
14
+ # <% if col.sortable %>
15
+ # <a href="<%= sort_link(col) %>"><%= col.label %></a>
16
+ # <% else %>
17
+ # <%= col.label %>
18
+ # <% end %>
19
+ # <% end %>
20
+ module TableHelper
21
+ # Build a sort link URL for a sortable column.
22
+ #
23
+ # This method generates a URL that includes sort and direction parameters,
24
+ # and preserves any existing filter query parameters. The sort direction
25
+ # cycles through three states when clicking the same column repeatedly:
26
+ # 1. No sort → asc
27
+ # 2. asc → desc
28
+ # 3. desc → no sort (removes sort parameters)
29
+ #
30
+ # When clicking a different column, it always starts with asc direction.
31
+ #
32
+ # @param column [ColumnDefinition] the column definition object
33
+ #
34
+ # @return [String] the URL with sort parameters and preserved filters
35
+ #
36
+ # @example First click on a column (no sort → asc)
37
+ # sort_link(column)
38
+ # # => "/admin/users?sort=email&dir=asc"
39
+ #
40
+ # @example Second click on same column (asc → desc)
41
+ # # Given: params[:sort] = "email", params[:dir] = "asc"
42
+ # sort_link(column)
43
+ # # => "/admin/users?sort=email&dir=desc"
44
+ #
45
+ # @example Third click on same column (desc → none)
46
+ # # Given: params[:sort] = "email", params[:dir] = "desc"
47
+ # sort_link(column)
48
+ # # => "/admin/users"
49
+ #
50
+ # @example Preserves existing filters
51
+ # # Given: params[:q] = { status: "active" }, params[:sort] = "email", params[:dir] = "asc"
52
+ # sort_link(column)
53
+ # # => "/admin/users?sort=email&dir=desc&q[status]=active"
54
+ #
55
+ # @example Clicking different column resets to asc
56
+ # # Given: params[:sort] = "email", params[:dir] = "desc"
57
+ # sort_link(name_column)
58
+ # # => "/admin/users?sort=name&dir=asc"
59
+ def sort_link(column)
60
+ current_sort = params[:sort]
61
+ current_dir = params[:dir]
62
+
63
+ # Determine new direction based on current state
64
+ new_dir = if current_sort == column.key.to_s
65
+ # Clicking same column: cycle through asc → desc → none
66
+ case current_dir
67
+ when 'asc'
68
+ 'desc'
69
+ when 'desc'
70
+ nil # Remove sort
71
+ else
72
+ 'asc'
73
+ end
74
+ else
75
+ # Clicking different column: start with asc
76
+ 'asc'
77
+ end
78
+
79
+ # Build URL with or without sort parameters
80
+ if new_dir
81
+ "#{request.path}?sort=#{column.key}&dir=#{new_dir}#{filter_query_string}"
82
+ elsif filter_query_string.present?
83
+ # No sort - just path + filters
84
+ "#{request.path}#{filter_query_string}"
85
+ else
86
+ request.path
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # Build a query string from filter parameters.
93
+ #
94
+ # This method extracts filter parameters from `params[:q]` and converts
95
+ # them into a URL-encoded query string that can be appended to URLs.
96
+ # The query string is prefixed with "&" for easy concatenation with
97
+ # existing parameters.
98
+ #
99
+ # Special characters in filter values are properly URL-encoded to prevent
100
+ # issues with spaces, ampersands, and other special characters.
101
+ #
102
+ # @return [String] the URL-encoded query string prefixed with "&", or empty string if no filters
103
+ #
104
+ # @example No filters
105
+ # filter_query_string
106
+ # # => ""
107
+ #
108
+ # @example Single filter
109
+ # # Given: params[:q] = { status: "active" }
110
+ # filter_query_string
111
+ # # => "&q[status]=active"
112
+ #
113
+ # @example Multiple filters
114
+ # # Given: params[:q] = { status: "active", email: "test@example.com" }
115
+ # filter_query_string
116
+ # # => "&q[status]=active&q[email]=test%40example.com"
117
+ #
118
+ # @example Special characters are URL-encoded
119
+ # # Given: params[:q] = { title: "test & value" }
120
+ # filter_query_string
121
+ # # => "&q[title]=test+%26+value"
122
+ #
123
+ # @api private
124
+ def filter_query_string
125
+ return '' unless params[:q].present?
126
+
127
+ filter_hash = params[:q].respond_to?(:to_unsafe_h) ? params[:q].to_unsafe_h : params[:q]
128
+ '&' + filter_hash.map { |k, v| "q[#{k}]=#{ERB::Util.url_encode(v)}" }.join('&')
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # View helper methods for theme-aware partial resolution in admin panel views.
6
+ #
7
+ # This helper provides a partial resolution system that follows a 3-level
8
+ # override chain, allowing host applications and themes to override engine
9
+ # default views. The helper is automatically included in all RSB::Admin
10
+ # controllers via the AdminController base class.
11
+ #
12
+ # @see RSB::Admin::Configuration#view_overrides_path
13
+ # @see RSB::Admin::Configuration#theme
14
+ # @see RSB::Admin::ThemeDefinition
15
+ #
16
+ # @example In a view
17
+ # <%= render rsb_admin_partial("shared/sidebar") %>
18
+ # # Resolves to first match:
19
+ # # 1. app/views/admin_overrides/shared/_sidebar.html.erb (if view_overrides_path set)
20
+ # # 2. app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb (if theme has views_path)
21
+ # # 3. rsb-admin/app/views/rsb/admin/shared/_sidebar.html.erb (engine default)
22
+ module ThemeHelper
23
+ # Resolves a partial path through the theme override chain.
24
+ #
25
+ # This method implements the view override resolution order (rule #16):
26
+ # 1. Host app override path (highest priority)
27
+ # 2. Theme override path
28
+ # 3. Engine default (fallback)
29
+ #
30
+ # The resolved path is returned as a string suitable for passing to `render`.
31
+ # This method is designed to be used with the `render` helper in views (rule #14).
32
+ #
33
+ # @param name [String] The partial name without leading underscore or extension
34
+ # (e.g., "shared/sidebar", "users/form")
35
+ #
36
+ # @return [String] The resolved partial path
37
+ #
38
+ # @example Basic usage (no overrides)
39
+ # rsb_admin_partial("shared/sidebar")
40
+ # # => "rsb/admin/shared/sidebar"
41
+ #
42
+ # @example With host app override
43
+ # # Given: RSB::Admin.configuration.view_overrides_path = "admin_overrides"
44
+ # # And file exists: app/views/admin_overrides/shared/_sidebar.html.erb
45
+ # rsb_admin_partial("shared/sidebar")
46
+ # # => "admin_overrides/shared/sidebar"
47
+ #
48
+ # @example With theme override
49
+ # # Given: RSB::Admin.configuration.theme = :modern
50
+ # # And theme.views_path = "rsb/admin/themes/modern/views"
51
+ # # And file exists: app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb
52
+ # rsb_admin_partial("shared/sidebar")
53
+ # # => "rsb/admin/themes/modern/views/shared/sidebar"
54
+ #
55
+ # @example Priority order
56
+ # # Host app override takes precedence over theme override
57
+ # # Theme override takes precedence over engine default
58
+ # rsb_admin_partial("users/form")
59
+ # # Checks in order:
60
+ # # 1. admin_overrides/users/_form.html.erb
61
+ # # 2. rsb/admin/themes/modern/views/users/_form.html.erb
62
+ # # 3. rsb/admin/users/_form.html.erb (always returned as fallback)
63
+ def rsb_admin_partial(name)
64
+ override_path = RSB::Admin.configuration.view_overrides_path
65
+ theme = RSB::Admin.current_theme
66
+
67
+ # 1. Host app override (highest priority)
68
+ if override_path
69
+ candidate = "#{override_path}/#{name}"
70
+ return candidate if lookup_context.exists?(candidate, [], true)
71
+ end
72
+
73
+ # 2. Theme override
74
+ if theme&.views_path
75
+ candidate = "#{theme.views_path}/#{name}"
76
+ return candidate if lookup_context.exists?(candidate, [], true)
77
+ end
78
+
79
+ # 3. Engine default (fallback)
80
+ "rsb/admin/#{name}"
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Dynamic URL helpers for admin resource and page paths.
6
+ #
7
+ # These helpers derive all URLs from the engine mount point (extracted from
8
+ # rsb_admin.dashboard_path) so they work correctly regardless of where the
9
+ # engine is mounted. They replace hardcoded "/admin/..." strings throughout
10
+ # views and controllers.
11
+ #
12
+ # @example In a view
13
+ # rsb_admin_resource_path("identities") # => "/admin/identities"
14
+ # rsb_admin_resource_show_path("identities", 42) # => "/admin/identities/42"
15
+ #
16
+ module UrlHelper
17
+ # Generate the index path for a resource.
18
+ #
19
+ # @param route_key [String] the resource's route key (e.g., "identities")
20
+ # @return [String] e.g., "/admin/identities"
21
+ #
22
+ # @example
23
+ # rsb_admin_resource_path("identities") # => "/admin/identities"
24
+ def rsb_admin_resource_path(route_key)
25
+ "#{rsb_admin_base_path}#{route_key}"
26
+ end
27
+
28
+ # Generate the show path for a specific resource record.
29
+ #
30
+ # @param route_key [String]
31
+ # @param id [Integer, String]
32
+ # @return [String] e.g., "/admin/identities/42"
33
+ #
34
+ # @example
35
+ # rsb_admin_resource_show_path("identities", 42) # => "/admin/identities/42"
36
+ def rsb_admin_resource_show_path(route_key, id)
37
+ "#{rsb_admin_base_path}#{route_key}/#{id}"
38
+ end
39
+
40
+ # Generate the new path for creating a resource record.
41
+ #
42
+ # @param route_key [String]
43
+ # @return [String] e.g., "/admin/identities/new"
44
+ #
45
+ # @example
46
+ # rsb_admin_resource_new_path("identities") # => "/admin/identities/new"
47
+ def rsb_admin_resource_new_path(route_key)
48
+ "#{rsb_admin_base_path}#{route_key}/new"
49
+ end
50
+
51
+ # Generate the edit path for a specific resource record.
52
+ #
53
+ # @param route_key [String]
54
+ # @param id [Integer, String]
55
+ # @return [String] e.g., "/admin/identities/42/edit"
56
+ #
57
+ # @example
58
+ # rsb_admin_resource_edit_path("identities", 42) # => "/admin/identities/42/edit"
59
+ def rsb_admin_resource_edit_path(route_key, id)
60
+ "#{rsb_admin_base_path}#{route_key}/#{id}/edit"
61
+ end
62
+
63
+ # Generate the path for a static page.
64
+ #
65
+ # @param page_key [String, Symbol]
66
+ # @return [String] e.g., "/admin/active_sessions"
67
+ #
68
+ # @example
69
+ # rsb_admin_page_path("active_sessions") # => "/admin/active_sessions"
70
+ def rsb_admin_page_path(page_key)
71
+ "#{rsb_admin_base_path}#{page_key}"
72
+ end
73
+
74
+ # Generate the path for a static page action.
75
+ #
76
+ # @param page_key [String, Symbol]
77
+ # @param action_key [String, Symbol]
78
+ # @return [String] e.g., "/admin/active_sessions/by_user"
79
+ #
80
+ # @example
81
+ # rsb_admin_page_action_path("active_sessions", "by_user") # => "/admin/active_sessions/by_user"
82
+ def rsb_admin_page_action_path(page_key, action_key)
83
+ "#{rsb_admin_base_path}#{page_key}/#{action_key}"
84
+ end
85
+
86
+ # Generate the path for a dashboard sub-action.
87
+ #
88
+ # @param action_key [String, Symbol] the action key (e.g., "metrics")
89
+ # @return [String] e.g., "/admin/dashboard/metrics"
90
+ #
91
+ # @example
92
+ # rsb_admin_dashboard_action_path("metrics") # => "/admin/dashboard/metrics"
93
+ def rsb_admin_dashboard_action_path(action_key)
94
+ "#{rsb_admin_base_path}dashboard/#{action_key}"
95
+ end
96
+
97
+ private
98
+
99
+ # Extract the engine mount point from the dashboard path.
100
+ # Returns the mount point with trailing slash (e.g., "/admin/").
101
+ #
102
+ # @return [String] the engine mount point with trailing slash
103
+ # @api private
104
+ def rsb_admin_base_path
105
+ @rsb_admin_base_path ||= rsb_admin.dashboard_path.chomp('dashboard')
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Mailer for admin panel transactional emails.
6
+ #
7
+ # Uses the host app's ActionMailer configuration (SMTP settings, delivery method).
8
+ # The `from` address is configurable via `RSB::Admin.configuration.mailer_sender`.
9
+ #
10
+ # @example Send verification email
11
+ # AdminMailer.email_verification(admin_user).deliver_later
12
+ #
13
+ class AdminMailer < ActionMailer::Base
14
+ # Sends a verification link to the admin's pending email address.
15
+ #
16
+ # @param admin_user [AdminUser] must have `pending_email` and `email_verification_token` set
17
+ # @return [Mail::Message]
18
+ def email_verification(admin_user)
19
+ @admin_user = admin_user
20
+ @verification_url = verify_email_url(admin_user.email_verification_token)
21
+
22
+ mail(
23
+ to: admin_user.pending_email,
24
+ from: RSB::Admin.configuration.mailer_sender,
25
+ subject: 'Verify your new email address'
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def verify_email_url(token)
32
+ RSB::Admin::Engine.routes.url_helpers.verify_email_profile_url(token: token,
33
+ host: default_url_options[:host] || 'localhost')
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents an active admin session with device and location tracking.
6
+ #
7
+ # Each sign-in creates an AdminSession record with a unique token stored in
8
+ # the cookie session. The token replaces the plain user ID for security.
9
+ # Device information is parsed from the User-Agent header.
10
+ #
11
+ # @example Create a session from a request
12
+ # session = AdminSession.create_from_request!(admin_user: user, request: request)
13
+ # session.session_token # => "abc123..."
14
+ # session.browser # => "Chrome"
15
+ # session.os # => "macOS"
16
+ #
17
+ class AdminSession < ApplicationRecord
18
+ belongs_to :admin_user
19
+
20
+ validates :session_token, presence: true, uniqueness: true
21
+ validates :last_active_at, presence: true
22
+
23
+ before_validation :generate_session_token, on: :create
24
+
25
+ # Parses a User-Agent string into browser, OS, and device type.
26
+ # Uses simple regex matching — no external gem dependency.
27
+ #
28
+ # @param user_agent [String, nil] the raw User-Agent header
29
+ # @return [Hash{Symbol => String}] keys: :browser, :os, :device_type
30
+ #
31
+ # @example
32
+ # AdminSession.parse_user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
33
+ # # => { browser: "Chrome", os: "macOS", device_type: "desktop" }
34
+ def self.parse_user_agent(user_agent)
35
+ ua = user_agent.to_s
36
+
37
+ browser = case ua
38
+ when %r{Edg/}i then 'Edge'
39
+ when %r{OPR/}i, /Opera/i then 'Opera'
40
+ when /Chrome/i then 'Chrome'
41
+ when /Firefox/i then 'Firefox'
42
+ when /Safari/i then 'Safari'
43
+ else 'Unknown'
44
+ end
45
+
46
+ os = case ua
47
+ when /Windows/i then 'Windows'
48
+ when /iPhone|iPad/i then 'iOS'
49
+ when /Android/i then 'Android'
50
+ when /Macintosh|Mac OS/i then 'macOS'
51
+ when /Linux/i then 'Linux'
52
+ else 'Unknown'
53
+ end
54
+
55
+ device_type = case ua
56
+ when /Mobile|iPhone|Android.*Mobile/i then 'mobile'
57
+ when /iPad|Tablet|Android(?!.*Mobile)/i then 'tablet'
58
+ else 'desktop'
59
+ end
60
+
61
+ { browser: browser, os: os, device_type: device_type }
62
+ end
63
+
64
+ # Creates a session record for the given admin user from a request.
65
+ #
66
+ # Parses the User-Agent header for device info, records IP address,
67
+ # and generates a unique session token.
68
+ #
69
+ # @param admin_user [AdminUser] the authenticated admin
70
+ # @param request [ActionDispatch::Request] the current request
71
+ # @return [AdminSession] the persisted session record
72
+ # @raise [ActiveRecord::RecordInvalid] if validation fails
73
+ def self.create_from_request!(admin_user:, request:)
74
+ parsed = parse_user_agent(request.user_agent)
75
+
76
+ create!(
77
+ admin_user: admin_user,
78
+ ip_address: request.remote_ip,
79
+ user_agent: request.user_agent,
80
+ browser: parsed[:browser],
81
+ os: parsed[:os],
82
+ device_type: parsed[:device_type],
83
+ last_active_at: Time.current
84
+ )
85
+ end
86
+
87
+ # Checks if this session matches the given token (i.e., is the "current" session).
88
+ #
89
+ # @param token [String] the session token from the cookie
90
+ # @return [Boolean]
91
+ def current?(token)
92
+ session_token == token
93
+ end
94
+
95
+ # Updates last_active_at without triggering callbacks or updating updated_at.
96
+ #
97
+ # @return [void]
98
+ def touch_activity!
99
+ update_column(:last_active_at, Time.current)
100
+ end
101
+
102
+ private
103
+
104
+ def generate_session_token
105
+ self.session_token ||= SecureRandom.urlsafe_base64(32)
106
+ end
107
+ end
108
+ end
109
+ end