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,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a custom page registration in the admin panel.
6
+ #
7
+ # PageRegistration is an immutable data structure that describes a custom admin
8
+ # page (not a resource-based CRUD interface). It handles page metadata, controller
9
+ # routing, and custom actions.
10
+ #
11
+ # @!attribute [r] key
12
+ # @return [Symbol] unique identifier for this page
13
+ # @!attribute [r] label
14
+ # @return [String] the human-readable page name
15
+ # @!attribute [r] icon
16
+ # @return [String, nil] optional icon identifier
17
+ # @!attribute [r] controller
18
+ # @return [String] the controller path (e.g., "admin/dashboard")
19
+ # @!attribute [r] category_name
20
+ # @return [String] the category this page belongs to
21
+ # @!attribute [r] actions
22
+ # @return [Array<Hash>] array of action definitions with :key, :label, :method, :confirm
23
+ #
24
+ # @example Building a simple dashboard page
25
+ # page = PageRegistration.build(
26
+ # key: :dashboard,
27
+ # label: "Dashboard",
28
+ # icon: "home",
29
+ # controller: "admin/dashboard",
30
+ # category_name: "System"
31
+ # )
32
+ #
33
+ # @example Building a page with custom actions
34
+ # page = PageRegistration.build(
35
+ # key: :usage,
36
+ # label: "Usage Reports",
37
+ # controller: "admin/usage",
38
+ # category_name: "Billing",
39
+ # actions: [
40
+ # { key: :index, label: "Overview" },
41
+ # { key: :export, label: "Export CSV", method: :post }
42
+ # ]
43
+ # )
44
+ PageRegistration = Data.define(
45
+ :key, # Symbol
46
+ :label, # String
47
+ :icon, # String | nil
48
+ :controller, # String
49
+ :category_name, # String
50
+ :actions # Array<Hash> — [{key: :index, label: "Overview"}, ...]
51
+ )
52
+
53
+ class PageRegistration
54
+ # Build a PageRegistration with normalized actions.
55
+ #
56
+ # @param key [Symbol, String] unique identifier for the page
57
+ # @param label [String] the display label
58
+ # @param icon [String, nil] optional icon identifier
59
+ # @param controller [String] the controller path
60
+ # @param category_name [String] the category name
61
+ # @param actions [Array<Hash>] array of action definitions (default: [])
62
+ # @return [PageRegistration] a frozen, immutable page registration
63
+ #
64
+ # @example
65
+ # PageRegistration.build(
66
+ # key: :settings,
67
+ # label: "Settings",
68
+ # controller: "admin/settings",
69
+ # category_name: "System"
70
+ # )
71
+ def self.build(key:, label:, controller:, category_name:, icon: nil, actions: [])
72
+ new(
73
+ key: key.to_sym,
74
+ label: label,
75
+ icon: icon,
76
+ controller: controller,
77
+ category_name: category_name,
78
+ actions: normalize_actions(actions)
79
+ )
80
+ end
81
+
82
+ # Wrap old-style Hash pages into PageRegistration for backwards compatibility.
83
+ #
84
+ # @param hash [Hash] legacy page hash with :key, :label, :icon, :controller, :category_name, :actions
85
+ # @return [PageRegistration] a frozen page registration
86
+ #
87
+ # @example
88
+ # legacy = { key: :sessions, label: "Sessions", controller: "admin/sessions", category_name: "Auth" }
89
+ # page = PageRegistration.from_hash(legacy)
90
+ def self.from_hash(hash)
91
+ build(
92
+ key: hash[:key],
93
+ label: hash[:label],
94
+ icon: hash[:icon],
95
+ controller: hash[:controller],
96
+ category_name: hash[:category_name],
97
+ actions: hash[:actions] || []
98
+ )
99
+ end
100
+
101
+ # Get all action keys for this page.
102
+ #
103
+ # @return [Array<Symbol>] array of action keys
104
+ #
105
+ # @example
106
+ # page.action_keys #=> [:index, :show, :export]
107
+ def action_keys
108
+ actions.map { |a| a[:key] }
109
+ end
110
+
111
+ # Find an action definition by key.
112
+ #
113
+ # @param key [Symbol, String] the action key to find
114
+ # @return [Hash, nil] the action hash or nil if not found
115
+ #
116
+ # @example
117
+ # action = page.find_action(:export)
118
+ # action[:method] #=> :post
119
+ def find_action(key)
120
+ actions.find { |a| a[:key] == key.to_sym }
121
+ end
122
+
123
+ # Normalize action definitions with defaults.
124
+ #
125
+ # @param actions [Array<Hash>] raw action definitions
126
+ # @return [Array<Hash>] normalized action hashes
127
+ # @api private
128
+ private_class_method def self.normalize_actions(actions)
129
+ actions.map do |action|
130
+ {
131
+ key: action[:key].to_sym,
132
+ label: action[:label] || action[:key].to_s.humanize,
133
+ method: (action[:method] || :get).to_sym,
134
+ confirm: action[:confirm]
135
+ }
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class Registry
6
+ attr_reader :categories, :dashboard_page
7
+
8
+ def initialize
9
+ @categories = {}
10
+ @dashboard_page = nil
11
+ end
12
+
13
+ # Register a category with resources and pages
14
+ def register_category(name, &block)
15
+ category = @categories[name] ||= CategoryRegistration.new(name)
16
+ category.instance_eval(&block) if block_given?
17
+ category
18
+ end
19
+
20
+ # Register resources into an existing category
21
+ def register_in(category_name, &block)
22
+ register_category(category_name, &block)
23
+ end
24
+
25
+ # Register a pre-built CategoryRegistration object
26
+ def register(category_registration)
27
+ name = category_registration.name
28
+ if @categories[name]
29
+ @categories[name].merge(category_registration)
30
+ else
31
+ @categories[name] = category_registration
32
+ end
33
+ end
34
+
35
+ # Register a custom dashboard page override.
36
+ #
37
+ # When registered, the built-in dashboard controller dispatches to the
38
+ # custom controller instead of rendering the default view. Calling this
39
+ # method multiple times replaces the previous registration (last-write-wins).
40
+ #
41
+ # @param controller [String] the controller path (e.g., "admin/dashboard")
42
+ # @param actions [Array<Hash>] optional action definitions for tab navigation
43
+ # (e.g., `[{ key: :index, label: "Overview" }, { key: :metrics, label: "Metrics" }]`)
44
+ #
45
+ # @return [PageRegistration] the created dashboard page registration
46
+ #
47
+ # @raise [ArgumentError] if controller is blank
48
+ #
49
+ # @example Simple override
50
+ # registry.register_dashboard(controller: "admin/dashboard")
51
+ #
52
+ # @example Override with tab actions
53
+ # registry.register_dashboard(
54
+ # controller: "admin/dashboard",
55
+ # actions: [
56
+ # { key: :index, label: "Overview" },
57
+ # { key: :metrics, label: "Metrics" }
58
+ # ]
59
+ # )
60
+ def register_dashboard(controller:, actions: [])
61
+ raise ArgumentError, 'controller must be present' if controller.nil? || controller.to_s.strip.empty?
62
+
63
+ @dashboard_page = PageRegistration.build(
64
+ key: :dashboard,
65
+ label: 'Dashboard',
66
+ icon: 'home',
67
+ controller: controller,
68
+ category_name: 'System',
69
+ actions: actions
70
+ )
71
+ end
72
+
73
+ # Query
74
+ def find_resource(model_class)
75
+ @categories.each_value do |cat|
76
+ resource = cat.find_resource(model_class)
77
+ return resource if resource
78
+ end
79
+ nil
80
+ end
81
+
82
+ def find_resource_by_route_key(key)
83
+ all_resources.find { |r| r.route_key == key }
84
+ end
85
+
86
+ def find_page_by_key(key)
87
+ key_sym = key.to_sym
88
+ categories.each_value do |category|
89
+ category.pages.each do |page|
90
+ return page if page.key == key_sym
91
+ end
92
+ end
93
+ nil
94
+ end
95
+
96
+ def category?(name)
97
+ @categories.key?(name)
98
+ end
99
+
100
+ def all_resources
101
+ @categories.values.flat_map(&:resources)
102
+ end
103
+
104
+ def all_pages
105
+ @categories.values.flat_map(&:pages)
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # DSL context for defining resource columns, filters, and form fields inline.
6
+ #
7
+ # This class provides the block context for the resource registration DSL.
8
+ # It allows you to declaratively define columns, filters, and form fields
9
+ # within a resource registration block.
10
+ #
11
+ # @!attribute [r] columns
12
+ # @return [Array<ColumnDefinition>] the column definitions added via {#column}
13
+ #
14
+ # @!attribute [r] filters
15
+ # @return [Array<FilterDefinition>] the filter definitions added via {#filter}
16
+ #
17
+ # @!attribute [r] form_fields
18
+ # @return [Array<FormFieldDefinition>] the form field definitions added via {#form_field}
19
+ #
20
+ # @example Defining columns, filters, and form fields for a User resource
21
+ # registry.register_category "Users" do
22
+ # resource User, icon: "users", actions: [:index, :show] do
23
+ # column :id, link: true
24
+ # column :email, sortable: true
25
+ # column :status, formatter: :badge
26
+ #
27
+ # filter :email, type: :text
28
+ # filter :status, type: :select, options: %w[active suspended]
29
+ #
30
+ # form_field :email, type: :email, required: true
31
+ # form_field :name, type: :text, required: true
32
+ # end
33
+ # end
34
+ #
35
+ # @see CategoryRegistration#resource
36
+ # @see ColumnDefinition
37
+ # @see FilterDefinition
38
+ # @see FormFieldDefinition
39
+ class ResourceDSLContext
40
+ attr_reader :columns, :filters, :form_fields
41
+
42
+ # Initialize a new DSL context with empty arrays.
43
+ #
44
+ # @api private
45
+ def initialize
46
+ @columns = []
47
+ @filters = []
48
+ @form_fields = []
49
+ end
50
+
51
+ # Define a column to display on index and show pages.
52
+ #
53
+ # Columns control how data is displayed in tables and detail views.
54
+ # Options are passed directly to {ColumnDefinition.build}.
55
+ #
56
+ # @param key [Symbol, String] the attribute name to display
57
+ # @param options [Hash] column configuration options
58
+ # @option options [String] :label the human-readable column header
59
+ # @option options [Boolean] :sortable whether the column can be sorted
60
+ # @option options [Symbol, Proc] :formatter optional formatter for the column value
61
+ # @option options [Boolean] :link whether to render the value as a link
62
+ # @option options [Array<Symbol>] :visible_on contexts where visible (`:index`, `:show`)
63
+ #
64
+ # @return [void]
65
+ #
66
+ # @example Basic column
67
+ # column :email
68
+ #
69
+ # @example Column with options
70
+ # column :status, label: "Account Status", sortable: true, formatter: :badge
71
+ #
72
+ # @example Column visible only on show page
73
+ # column :notes, visible_on: [:show]
74
+ #
75
+ # @see ColumnDefinition.build
76
+ def column(key, **options)
77
+ @columns << ColumnDefinition.build(key, **options)
78
+ end
79
+
80
+ # Define a filter for querying records on the index page.
81
+ #
82
+ # Filters allow users to narrow down the displayed records.
83
+ # Options are passed directly to {FilterDefinition.build}.
84
+ #
85
+ # @param key [Symbol, String] the attribute name to filter on
86
+ # @param options [Hash] filter configuration options
87
+ # @option options [String] :label the human-readable filter label
88
+ # @option options [Symbol] :type the filter type (`:text`, `:select`, `:boolean`, `:date_range`, `:number_range`)
89
+ # @option options [Array, Proc] :options options for select-type filters
90
+ # @option options [Symbol, Proc] :scope custom filtering logic
91
+ #
92
+ # @return [void]
93
+ #
94
+ # @example Text filter
95
+ # filter :email, type: :text
96
+ #
97
+ # @example Select filter with options
98
+ # filter :status, type: :select, options: %w[active suspended banned]
99
+ #
100
+ # @example Filter with custom scope
101
+ # filter :search, scope: ->(rel, val) { rel.where("name LIKE ? OR email LIKE ?", "%#{val}%", "%#{val}%") }
102
+ #
103
+ # @see FilterDefinition.build
104
+ def filter(key, **options)
105
+ @filters << FilterDefinition.build(key, **options)
106
+ end
107
+
108
+ # Define a form field for new and edit forms.
109
+ #
110
+ # Form fields control how data is entered on create/update forms.
111
+ # Options are passed directly to {FormFieldDefinition.build}.
112
+ #
113
+ # @param key [Symbol, String] the attribute name for this field
114
+ # @param options [Hash] form field configuration options
115
+ # @option options [String] :label the human-readable field label
116
+ # @option options [Symbol] :type the field type (`:text`, `:textarea`, `:select`, `:checkbox`, `:number`, `:email`, `:password`, `:datetime`, `:hidden`, `:json`)
117
+ # @option options [Array, Proc] :options options for select-type fields
118
+ # @option options [Boolean] :required whether the field is required
119
+ # @option options [String] :hint optional help text displayed below the field
120
+ # @option options [Array<Symbol>] :visible_on contexts where visible (`:new`, `:edit`)
121
+ #
122
+ # @return [void]
123
+ #
124
+ # @example Required email field
125
+ # form_field :email, type: :email, required: true
126
+ #
127
+ # @example Textarea with hint
128
+ # form_field :bio, type: :textarea, hint: "Tell us about yourself"
129
+ #
130
+ # @example Field visible only on new form
131
+ # form_field :password, type: :password, required: true, visible_on: [:new]
132
+ #
133
+ # @see FormFieldDefinition.build
134
+ def form_field(key, **options)
135
+ @form_fields << FormFieldDefinition.build(key, **options)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a registered resource in the admin panel.
6
+ #
7
+ # ResourceRegistration stores metadata about a model that should be accessible
8
+ # in the admin interface, including its category, actions, icon, and optionally
9
+ # a custom controller to handle requests.
10
+ #
11
+ # @example Registering a resource with default generic controller
12
+ # ResourceRegistration.new(
13
+ # model_class: User,
14
+ # category_name: "Users",
15
+ # icon: "user",
16
+ # actions: [:index, :show]
17
+ # )
18
+ #
19
+ # @example Registering a resource with custom controller
20
+ # ResourceRegistration.new(
21
+ # model_class: Identity,
22
+ # category_name: "Authentication",
23
+ # icon: "users",
24
+ # actions: [:index, :show, :suspend, :activate],
25
+ # controller: "rsb/auth/admin/identities"
26
+ # )
27
+ class ResourceRegistration
28
+ # @return [Class] the ActiveRecord model class this registration represents
29
+ attr_reader :model_class
30
+
31
+ # @return [String] the name of the category this resource belongs to
32
+ attr_reader :category_name
33
+
34
+ # @return [String, nil] the icon identifier for this resource
35
+ attr_reader :icon
36
+
37
+ # @return [String] the human-readable label for this resource
38
+ attr_reader :label
39
+
40
+ # @return [Array<Symbol>] the list of allowed actions for this resource
41
+ attr_reader :actions
42
+
43
+ # @return [Hash] additional options passed during registration
44
+ attr_reader :options
45
+
46
+ # @return [String, nil] the controller path for custom controller (e.g., "rsb/auth/admin/identities")
47
+ attr_reader :controller
48
+
49
+ # @return [Array<ColumnDefinition>, nil] column definitions for this resource
50
+ attr_reader :columns
51
+
52
+ # @return [Array<FilterDefinition>, nil] filter definitions for this resource
53
+ attr_reader :filters
54
+
55
+ # @return [Array<FormFieldDefinition>, nil] form field definitions for this resource
56
+ attr_reader :form_fields
57
+
58
+ # @return [Integer, nil] number of records per page
59
+ attr_reader :per_page
60
+
61
+ # @return [Hash, nil] default sort configuration (e.g., { column: :created_at, direction: :desc })
62
+ attr_reader :default_sort
63
+
64
+ # @return [Array<Symbol>, nil] searchable field names
65
+ attr_reader :search_fields
66
+
67
+ # Initialize a new resource registration.
68
+ #
69
+ # @param model_class [Class] the ActiveRecord model class
70
+ # @param category_name [String] the category this resource belongs to
71
+ # @param icon [String, nil] optional icon identifier
72
+ # @param label [String, nil] optional custom label (defaults to humanized plural model name)
73
+ # @param actions [Array<Symbol>] allowed actions (default: [])
74
+ # @param controller [String, nil] optional custom controller path for delegation
75
+ # @param columns [Array<ColumnDefinition>, nil] column definitions
76
+ # @param filters [Array<FilterDefinition>, nil] filter definitions
77
+ # @param form_fields [Array<FormFieldDefinition>, nil] form field definitions
78
+ # @param per_page [Integer, nil] records per page
79
+ # @param default_sort [Hash, nil] default sort configuration
80
+ # @param search_fields [Array<Symbol>, nil] searchable field names
81
+ # @param options [Hash] additional options
82
+ def initialize(model_class:, category_name:, icon: nil, label: nil, actions: [], controller: nil,
83
+ columns: nil, filters: nil, form_fields: nil,
84
+ per_page: nil, default_sort: nil, search_fields: nil,
85
+ **options)
86
+ @model_class = model_class
87
+ @category_name = category_name
88
+ @icon = icon
89
+ @label = label || model_class.model_name.human.pluralize
90
+ @actions = actions.map(&:to_sym)
91
+ @controller = controller
92
+ @columns = columns
93
+ @filters = filters
94
+ @form_fields = form_fields
95
+ @per_page = per_page
96
+ @default_sort = default_sort
97
+ @search_fields = search_fields&.map(&:to_sym)
98
+ @options = options
99
+ end
100
+
101
+ # Check if a specific action is allowed for this resource.
102
+ #
103
+ # @param action [Symbol, String] the action to check
104
+ # @return [Boolean] true if the action is in the allowed actions list
105
+ def action?(action)
106
+ @actions.include?(action.to_sym)
107
+ end
108
+
109
+ # Check if this resource uses a custom controller.
110
+ #
111
+ # When true, requests to this resource will be delegated to the custom
112
+ # controller instead of being handled by the generic ResourcesController.
113
+ #
114
+ # @return [Boolean] true if a custom controller is configured
115
+ def custom_controller?
116
+ @controller.present?
117
+ end
118
+
119
+ # Get the route key for this resource's model.
120
+ #
121
+ # @return [String] the pluralized route key (e.g., "identities" for Identity model)
122
+ def route_key
123
+ model_class.model_name.route_key
124
+ end
125
+
126
+ # Returns columns visible on index views.
127
+ #
128
+ # If no columns were explicitly defined via the DSL, this method
129
+ # auto-detects columns from the model's database schema, excluding
130
+ # sensitive columns (passwords, tokens, etc.).
131
+ #
132
+ # @return [Array<ColumnDefinition>] columns to display on index view
133
+ #
134
+ # @example With explicit columns
135
+ # registration.columns #=> [ColumnDefinition(:id), ColumnDefinition(:email)]
136
+ # registration.index_columns #=> [ColumnDefinition(:id), ColumnDefinition(:email)]
137
+ #
138
+ # @example With auto-detection
139
+ # registration.columns #=> nil
140
+ # registration.index_columns #=> [ColumnDefinition(:id), ColumnDefinition(:email), ...]
141
+ #
142
+ # @see #auto_detect_columns
143
+ def index_columns
144
+ return auto_detect_columns.select { |c| c.visible_on?(:index) } unless columns
145
+
146
+ columns.select { |c| c.visible_on?(:index) }
147
+ end
148
+
149
+ # Returns columns visible on show (detail) views.
150
+ #
151
+ # If no columns were explicitly defined via the DSL, this method
152
+ # auto-detects columns from the model's database schema, excluding
153
+ # sensitive columns (passwords, tokens, etc.).
154
+ #
155
+ # Show views typically display more columns than index views, including
156
+ # timestamps and metadata fields.
157
+ #
158
+ # @return [Array<ColumnDefinition>] columns to display on show view
159
+ #
160
+ # @see #auto_detect_columns
161
+ def show_columns
162
+ return auto_detect_columns.select { |c| c.visible_on?(:show) } unless columns
163
+
164
+ columns.select { |c| c.visible_on?(:show) }
165
+ end
166
+
167
+ # Returns form fields for new (create) forms.
168
+ #
169
+ # If no form fields were explicitly defined via the DSL, this method
170
+ # auto-detects editable fields from the model's database schema,
171
+ # excluding sensitive columns, ID, and timestamps.
172
+ #
173
+ # @return [Array<FormFieldDefinition>] form fields for new form
174
+ #
175
+ # @example With explicit form fields
176
+ # registration.form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name)]
177
+ # registration.new_form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name)]
178
+ #
179
+ # @example With auto-detection
180
+ # registration.form_fields #=> nil
181
+ # registration.new_form_fields #=> [FormFieldDefinition(:email), FormFieldDefinition(:name), ...]
182
+ #
183
+ # @see #auto_detect_form_fields
184
+ def new_form_fields
185
+ return auto_detect_form_fields.select { |f| f.visible_on?(:new) } unless form_fields
186
+
187
+ form_fields.select { |f| f.visible_on?(:new) }
188
+ end
189
+
190
+ # Returns form fields for edit (update) forms.
191
+ #
192
+ # If no form fields were explicitly defined via the DSL, this method
193
+ # auto-detects editable fields from the model's database schema,
194
+ # excluding sensitive columns, ID, and timestamps.
195
+ #
196
+ # @return [Array<FormFieldDefinition>] form fields for edit form
197
+ #
198
+ # @see #auto_detect_form_fields
199
+ def edit_form_fields
200
+ return auto_detect_form_fields.select { |f| f.visible_on?(:edit) } unless form_fields
201
+
202
+ form_fields.select { |f| f.visible_on?(:edit) }
203
+ end
204
+
205
+ private
206
+
207
+ # Column names that contain sensitive data and should never be displayed.
208
+ #
209
+ # These columns are excluded from auto-detection to prevent accidental
210
+ # exposure of passwords, tokens, and other secrets in the admin interface.
211
+ #
212
+ # @api private
213
+ SENSITIVE_COLUMNS = %w[
214
+ password_digest token encrypted_password
215
+ reset_password_token confirmation_token
216
+ unlock_token otp_secret
217
+ ].freeze
218
+
219
+ # Column names that should be excluded from auto-detected form fields.
220
+ #
221
+ # These are typically read-only columns managed by the database or
222
+ # framework (ID, timestamps) that users should not edit directly.
223
+ #
224
+ # @api private
225
+ SKIP_FORM_COLUMNS = %w[
226
+ id created_at updated_at
227
+ ].freeze
228
+
229
+ # Column names that should be excluded from index tables.
230
+ #
231
+ # These are typically noisy columns (timestamps, metadata) that clutter
232
+ # table views but are useful in detail views.
233
+ #
234
+ # @api private
235
+ SKIP_INDEX_COLUMNS = %w[
236
+ created_at updated_at metadata
237
+ ].freeze
238
+
239
+ # Auto-detect column definitions from the model's database schema.
240
+ #
241
+ # This method introspects the model's columns and creates a
242
+ # {ColumnDefinition} for each non-sensitive column. Used as a fallback
243
+ # when no explicit columns are defined via the DSL.
244
+ #
245
+ # Columns in SKIP_INDEX_COLUMNS are marked as visible only on show views,
246
+ # not index views.
247
+ #
248
+ # @return [Array<ColumnDefinition>] auto-detected column definitions
249
+ # @return [Array] empty array if the model doesn't respond to `column_names`
250
+ #
251
+ # @api private
252
+ def auto_detect_columns
253
+ return [] unless model_class.respond_to?(:column_names)
254
+
255
+ model_class.column_names
256
+ .reject { |c| SENSITIVE_COLUMNS.include?(c) }
257
+ .map do |c|
258
+ # Skip index for timestamp/metadata columns
259
+ if SKIP_INDEX_COLUMNS.include?(c)
260
+ ColumnDefinition.build(c.to_sym, visible_on: [:show])
261
+ else
262
+ ColumnDefinition.build(c.to_sym)
263
+ end
264
+ end
265
+ end
266
+
267
+ # Auto-detect form field definitions from the model's database schema.
268
+ #
269
+ # This method introspects the model's columns and creates a
270
+ # {FormFieldDefinition} for each editable column. Excludes sensitive
271
+ # columns, ID, and timestamps. Used as a fallback when no explicit
272
+ # form fields are defined via the DSL.
273
+ #
274
+ # @return [Array<FormFieldDefinition>] auto-detected form field definitions
275
+ # @return [Array] empty array if the model doesn't respond to `column_names`
276
+ #
277
+ # @api private
278
+ def auto_detect_form_fields
279
+ return [] unless model_class.respond_to?(:column_names)
280
+
281
+ model_class.column_names
282
+ .reject { |c| SENSITIVE_COLUMNS.include?(c) || SKIP_FORM_COLUMNS.include?(c) }
283
+ .map { |c| FormFieldDefinition.build(c.to_sym) }
284
+ end
285
+ end
286
+ end
287
+ end