rsb-admin 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +83 -0
- data/Rakefile +25 -0
- data/app/assets/javascripts/rsb/admin/themes/modern.js +37 -0
- data/app/assets/stylesheets/rsb/admin/themes/default.css +1358 -0
- data/app/assets/stylesheets/rsb/admin/themes/modern.css +1370 -0
- data/app/controllers/concerns/rsb/admin/authorization.rb +21 -0
- data/app/controllers/rsb/admin/admin_controller.rb +138 -0
- data/app/controllers/rsb/admin/admin_users_controller.rb +110 -0
- data/app/controllers/rsb/admin/dashboard_controller.rb +76 -0
- data/app/controllers/rsb/admin/profile_controller.rb +146 -0
- data/app/controllers/rsb/admin/profile_sessions_controller.rb +45 -0
- data/app/controllers/rsb/admin/resources_controller.rb +386 -0
- data/app/controllers/rsb/admin/roles_controller.rb +99 -0
- data/app/controllers/rsb/admin/sessions_controller.rb +139 -0
- data/app/controllers/rsb/admin/settings_controller.rb +203 -0
- data/app/controllers/rsb/admin/two_factor_controller.rb +105 -0
- data/app/helpers/rsb/admin/authorization_helper.rb +49 -0
- data/app/helpers/rsb/admin/branding_helper.rb +38 -0
- data/app/helpers/rsb/admin/formatting_helper.rb +205 -0
- data/app/helpers/rsb/admin/i18n_helper.rb +148 -0
- data/app/helpers/rsb/admin/icons_helper.rb +55 -0
- data/app/helpers/rsb/admin/table_helper.rb +132 -0
- data/app/helpers/rsb/admin/theme_helper.rb +84 -0
- data/app/helpers/rsb/admin/url_helper.rb +109 -0
- data/app/mailers/rsb/admin/admin_mailer.rb +37 -0
- data/app/models/rsb/admin/admin_session.rb +109 -0
- data/app/models/rsb/admin/admin_user.rb +153 -0
- data/app/models/rsb/admin/application_record.rb +10 -0
- data/app/models/rsb/admin/role.rb +63 -0
- data/app/views/layouts/rsb/admin/application.html.erb +45 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.html.erb +11 -0
- data/app/views/rsb/admin/admin_mailer/email_verification.text.erb +11 -0
- data/app/views/rsb/admin/admin_users/_form.html.erb +52 -0
- data/app/views/rsb/admin/admin_users/edit.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/index.html.erb +77 -0
- data/app/views/rsb/admin/admin_users/new.html.erb +10 -0
- data/app/views/rsb/admin/admin_users/show.html.erb +85 -0
- data/app/views/rsb/admin/dashboard/index.html.erb +36 -0
- data/app/views/rsb/admin/profile/edit.html.erb +67 -0
- data/app/views/rsb/admin/profile/show.html.erb +155 -0
- data/app/views/rsb/admin/resources/_filters.html.erb +58 -0
- data/app/views/rsb/admin/resources/_form.html.erb +20 -0
- data/app/views/rsb/admin/resources/_pagination.html.erb +33 -0
- data/app/views/rsb/admin/resources/_table.html.erb +70 -0
- data/app/views/rsb/admin/resources/edit.html.erb +7 -0
- data/app/views/rsb/admin/resources/index.html.erb +49 -0
- data/app/views/rsb/admin/resources/new.html.erb +7 -0
- data/app/views/rsb/admin/resources/page.html.erb +9 -0
- data/app/views/rsb/admin/resources/show.html.erb +55 -0
- data/app/views/rsb/admin/roles/_form.html.erb +197 -0
- data/app/views/rsb/admin/roles/edit.html.erb +7 -0
- data/app/views/rsb/admin/roles/index.html.erb +71 -0
- data/app/views/rsb/admin/roles/new.html.erb +7 -0
- data/app/views/rsb/admin/roles/show.html.erb +99 -0
- data/app/views/rsb/admin/sessions/new.html.erb +31 -0
- data/app/views/rsb/admin/sessions/two_factor.html.erb +39 -0
- data/app/views/rsb/admin/settings/_field.html.erb +115 -0
- data/app/views/rsb/admin/settings/index.html.erb +61 -0
- data/app/views/rsb/admin/shared/_badge.html.erb +1 -0
- data/app/views/rsb/admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/rsb/admin/shared/_empty_state.html.erb +4 -0
- data/app/views/rsb/admin/shared/_flash.html.erb +22 -0
- data/app/views/rsb/admin/shared/_header.html.erb +50 -0
- data/app/views/rsb/admin/shared/_page_tabs.html.erb +21 -0
- data/app/views/rsb/admin/shared/_sidebar.html.erb +99 -0
- data/app/views/rsb/admin/shared/disabled.html.erb +38 -0
- data/app/views/rsb/admin/shared/fields/_checkbox.html.erb +6 -0
- data/app/views/rsb/admin/shared/fields/_datetime.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_email.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_hidden.html.erb +1 -0
- data/app/views/rsb/admin/shared/fields/_json.html.erb +11 -0
- data/app/views/rsb/admin/shared/fields/_number.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_password.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_select.html.erb +12 -0
- data/app/views/rsb/admin/shared/fields/_text.html.erb +10 -0
- data/app/views/rsb/admin/shared/fields/_textarea.html.erb +10 -0
- data/app/views/rsb/admin/shared/forbidden.html.erb +22 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_header.html.erb +77 -0
- data/app/views/rsb/admin/themes/modern/views/shared/_sidebar.html.erb +135 -0
- data/app/views/rsb/admin/two_factor/backup_codes.html.erb +48 -0
- data/app/views/rsb/admin/two_factor/new.html.erb +53 -0
- data/config/locales/en.yml +140 -0
- data/config/locales/seo.en.yml +21 -0
- data/config/routes.rb +59 -0
- data/db/migrate/20260208000003_create_rsb_admin_tables.rb +43 -0
- data/db/migrate/20260214000001_add_otp_fields_to_rsb_admin_admin_users.rb +9 -0
- data/lib/generators/rsb/admin/install/install_generator.rb +45 -0
- data/lib/generators/rsb/admin/install/templates/rsb_admin_seeds.rb +24 -0
- data/lib/generators/rsb/admin/theme/templates/theme.css.tt +66 -0
- data/lib/generators/rsb/admin/theme/theme_generator.rb +218 -0
- data/lib/generators/rsb/admin/views/views_generator.rb +262 -0
- data/lib/rsb/admin/breadcrumb_item.rb +26 -0
- data/lib/rsb/admin/category_registration.rb +177 -0
- data/lib/rsb/admin/column_definition.rb +89 -0
- data/lib/rsb/admin/configuration.rb +69 -0
- data/lib/rsb/admin/engine.rb +34 -0
- data/lib/rsb/admin/filter_definition.rb +129 -0
- data/lib/rsb/admin/form_field_definition.rb +96 -0
- data/lib/rsb/admin/icons.rb +95 -0
- data/lib/rsb/admin/page_registration.rb +140 -0
- data/lib/rsb/admin/registry.rb +109 -0
- data/lib/rsb/admin/resource_dsl_context.rb +139 -0
- data/lib/rsb/admin/resource_registration.rb +287 -0
- data/lib/rsb/admin/settings_schema.rb +60 -0
- data/lib/rsb/admin/test_kit/helpers.rb +316 -0
- data/lib/rsb/admin/test_kit/resource_test_case.rb +193 -0
- data/lib/rsb/admin/test_kit.rb +11 -0
- data/lib/rsb/admin/theme_definition.rb +46 -0
- data/lib/rsb/admin/themes/modern.rb +44 -0
- data/lib/rsb/admin/version.rb +9 -0
- data/lib/rsb/admin.rb +177 -0
- data/lib/tasks/rsb/admin_tasks.rake +23 -0
- metadata +227 -0
|
@@ -0,0 +1,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=""><script>alert("xss")</script>" ...>...</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
|