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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a column definition for admin resource tables.
6
+ #
7
+ # ColumnDefinition is an immutable data structure that describes how a column
8
+ # should be displayed in index and show views. It handles column metadata,
9
+ # formatting, sorting, and visibility rules.
10
+ #
11
+ # @!attribute [r] key
12
+ # @return [Symbol] the attribute name to display
13
+ # @!attribute [r] label
14
+ # @return [String] the human-readable column header
15
+ # @!attribute [r] sortable
16
+ # @return [Boolean] whether the column can be sorted
17
+ # @!attribute [r] formatter
18
+ # @return [Symbol, Proc, nil] optional formatter for the column value
19
+ # @!attribute [r] link
20
+ # @return [Boolean] whether to render the value as a link to the resource
21
+ # @!attribute [r] visible_on
22
+ # @return [Array<Symbol>] contexts where this column is visible (:index, :show)
23
+ #
24
+ # @example Building a simple column
25
+ # col = ColumnDefinition.build(:email)
26
+ # col.key #=> :email
27
+ # col.label #=> "Email"
28
+ # col.sortable #=> false
29
+ # col.link #=> false
30
+ #
31
+ # @example Building an ID column (link defaults to true)
32
+ # col = ColumnDefinition.build(:id)
33
+ # col.link #=> true
34
+ #
35
+ # @example Building a custom column with formatter
36
+ # col = ColumnDefinition.build(:status,
37
+ # label: "State",
38
+ # sortable: true,
39
+ # formatter: :badge,
40
+ # visible_on: [:index]
41
+ # )
42
+ ColumnDefinition = Data.define(
43
+ :key, # Symbol
44
+ :label, # String
45
+ :sortable, # Boolean
46
+ :formatter, # Symbol | Proc | nil
47
+ :link, # Boolean
48
+ :visible_on # Array<Symbol>
49
+ )
50
+
51
+ class ColumnDefinition
52
+ # Build a ColumnDefinition with smart defaults.
53
+ #
54
+ # @param key [Symbol, String] the attribute name
55
+ # @param label [String, nil] the display label (defaults to humanized key)
56
+ # @param sortable [Boolean] whether the column is sortable (default: false)
57
+ # @param formatter [Symbol, Proc, nil] optional value formatter
58
+ # @param link [Boolean, nil] whether to link the value (default: true for :id, false otherwise)
59
+ # @param visible_on [Symbol, Array<Symbol>] contexts where visible (default: [:index, :show])
60
+ # @return [ColumnDefinition] a frozen, immutable column definition
61
+ #
62
+ # @example
63
+ # ColumnDefinition.build(:created_at, label: "Created", sortable: true)
64
+ def self.build(key, label: nil, sortable: false, formatter: nil, link: nil, visible_on: %i[index show])
65
+ new(
66
+ key: key.to_sym,
67
+ label: label || key.to_s.humanize,
68
+ sortable: sortable,
69
+ formatter: formatter,
70
+ link: link.nil? ? (key.to_sym == :id) : link,
71
+ visible_on: Array(visible_on).map(&:to_sym)
72
+ )
73
+ end
74
+
75
+ # Check if this column is visible in a given context.
76
+ #
77
+ # @param context [Symbol, String] the rendering context (:index or :show)
78
+ # @return [Boolean] true if the column should be displayed in this context
79
+ #
80
+ # @example
81
+ # col = ColumnDefinition.build(:email, visible_on: [:index])
82
+ # col.visible_on?(:index) #=> true
83
+ # col.visible_on?(:show) #=> false
84
+ def visible_on?(context)
85
+ visible_on.include?(context.to_sym)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Holds configuration options for the RSB Admin panel.
6
+ #
7
+ # Instances are created by {RSB::Admin.configuration} and configured
8
+ # via {RSB::Admin.configure}. Each attribute has a sensible default
9
+ # that can be overridden in an initializer.
10
+ #
11
+ # @example
12
+ # RSB::Admin.configure do |config|
13
+ # config.app_name = "My App Admin"
14
+ # config.theme = :modern
15
+ # config.enabled = true
16
+ # config.company_name = "Acme Corp"
17
+ # config.logo_url = "/images/logo.svg"
18
+ # config.footer_text = "© 2024 Acme Corp"
19
+ # end
20
+ class Configuration
21
+ # @return [Boolean] whether the admin panel is enabled
22
+ attr_accessor :enabled
23
+
24
+ # @return [String] the display name shown in the admin panel header
25
+ attr_accessor :app_name
26
+
27
+ # @return [String] company or product name shown in footer/login
28
+ attr_accessor :company_name
29
+
30
+ # @return [String] URL to logo image shown in sidebar header
31
+ attr_accessor :logo_url
32
+
33
+ # @return [String] custom footer text
34
+ attr_accessor :footer_text
35
+
36
+ # @return [Integer] default number of records per page in index views
37
+ attr_accessor :per_page
38
+
39
+ # @return [Symbol] the active theme key (must match a registered theme)
40
+ attr_accessor :theme
41
+
42
+ # @return [String, nil] optional path prefix for host-app view overrides
43
+ attr_accessor :view_overrides_path
44
+
45
+ # @return [String] the layout template used by admin controllers
46
+ attr_accessor :layout
47
+
48
+ # @return [String] from address for admin emails
49
+ attr_accessor :mailer_sender
50
+
51
+ # @return [ActiveSupport::Duration] verification token lifetime
52
+ attr_accessor :email_verification_expiry
53
+
54
+ def initialize
55
+ @enabled = true
56
+ @app_name = 'Admin'
57
+ @company_name = ''
58
+ @logo_url = ''
59
+ @footer_text = ''
60
+ @per_page = 25
61
+ @theme = :default
62
+ @view_overrides_path = nil
63
+ @layout = 'rsb/admin/application'
64
+ @mailer_sender = 'no-reply@example.com'
65
+ @email_verification_expiry = 24.hours
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace RSB::Admin
7
+
8
+ initializer 'rsb_admin.i18n' do
9
+ config.i18n.load_path += Dir[RSB::Admin::Engine.root.join('config', 'locales', '**', '*.yml')]
10
+ end
11
+
12
+ # Register settings schema
13
+ initializer 'rsb_admin.register_settings', after: 'rsb_settings.ready' do
14
+ RSB::Settings.registry.register(RSB::Admin.settings_schema)
15
+ end
16
+
17
+ # Trigger on_load hooks — this is where rsb-auth, rsb-entitlements,
18
+ # and third-party gems register their admin sections.
19
+ # Deferred to after_initialize so all engine autoload paths are set up
20
+ # and model constants (e.g. RSB::Auth::Identity) are resolvable.
21
+ initializer 'rsb_admin.ready' do |app|
22
+ app.config.after_initialize do
23
+ ActiveSupport.run_load_hooks(:rsb_admin, RSB::Admin.registry)
24
+ end
25
+ end
26
+
27
+ config.generators do |g|
28
+ g.test_framework :minitest, fixture: false
29
+ g.assets false
30
+ g.helper false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a filter definition for admin resource queries.
6
+ #
7
+ # FilterDefinition is an immutable data structure that describes how to filter
8
+ # an ActiveRecord relation. It supports multiple filter types (text, select,
9
+ # boolean, date ranges, number ranges) and can use custom scopes or default
10
+ # filtering logic.
11
+ #
12
+ # @!attribute [r] key
13
+ # @return [Symbol] the attribute name to filter on
14
+ # @!attribute [r] label
15
+ # @return [String] the human-readable filter label
16
+ # @!attribute [r] type
17
+ # @return [Symbol] the filter type (:text, :select, :boolean, :date_range, :number_range)
18
+ # @!attribute [r] options
19
+ # @return [Array, Proc, nil] options for select filters (array or callable)
20
+ # @!attribute [r] scope
21
+ # @return [Symbol, Proc, nil] custom scope method name or lambda for filtering
22
+ #
23
+ # @example Building a text filter
24
+ # filter = FilterDefinition.build(:email)
25
+ # filter.type #=> :text
26
+ #
27
+ # @example Building a select filter with options
28
+ # filter = FilterDefinition.build(:status,
29
+ # type: :select,
30
+ # options: %w[active suspended banned]
31
+ # )
32
+ #
33
+ # @example Building a filter with custom scope
34
+ # filter = FilterDefinition.build(:search,
35
+ # scope: ->(rel, val) { rel.where("name LIKE ? OR email LIKE ?", "%#{val}%", "%#{val}%") }
36
+ # )
37
+ FilterDefinition = Data.define(
38
+ :key, # Symbol
39
+ :label, # String
40
+ :type, # Symbol — :text, :select, :boolean, :date_range, :number_range
41
+ :options, # Array | Proc | nil
42
+ :scope # Symbol | Proc | nil
43
+ )
44
+
45
+ class FilterDefinition
46
+ # Build a FilterDefinition with smart defaults.
47
+ #
48
+ # @param key [Symbol, String] the attribute name to filter
49
+ # @param label [String, nil] the display label (defaults to humanized key)
50
+ # @param type [Symbol] the filter type (default: :text)
51
+ # @param options [Array, Proc, nil] options for select-type filters
52
+ # @param scope [Symbol, Proc, nil] custom filtering logic
53
+ # @return [FilterDefinition] a frozen, immutable filter definition
54
+ #
55
+ # @example
56
+ # FilterDefinition.build(:created_at, type: :date_range)
57
+ def self.build(key, label: nil, type: :text, options: nil, scope: nil)
58
+ new(
59
+ key: key.to_sym,
60
+ label: label || key.to_s.humanize,
61
+ type: type.to_sym,
62
+ options: options,
63
+ scope: scope
64
+ )
65
+ end
66
+
67
+ # Apply this filter to an ActiveRecord relation.
68
+ #
69
+ # If the filter has a custom scope (Proc or Symbol), it will be used.
70
+ # Otherwise, default filtering logic based on the filter type will be applied.
71
+ #
72
+ # @param relation [ActiveRecord::Relation] the relation to filter
73
+ # @param value [Object] the filter value (ignored if blank)
74
+ # @return [ActiveRecord::Relation] the filtered relation
75
+ #
76
+ # @example Applying a text filter
77
+ # filter = FilterDefinition.build(:email, type: :text)
78
+ # filtered = filter.apply(User.all, "john")
79
+ # # Generates: WHERE email LIKE '%john%'
80
+ #
81
+ # @example Applying with blank value (no-op)
82
+ # filter.apply(User.all, "") #=> returns User.all unchanged
83
+ def apply(relation, value)
84
+ return relation if value.blank?
85
+
86
+ if scope.is_a?(Proc)
87
+ scope.call(relation, value)
88
+ elsif scope.is_a?(Symbol)
89
+ relation.send(scope, value)
90
+ else
91
+ apply_default_scope(relation, value)
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Apply default filtering logic based on the filter type.
98
+ #
99
+ # @param relation [ActiveRecord::Relation] the relation to filter
100
+ # @param value [Object] the filter value
101
+ # @return [ActiveRecord::Relation] the filtered relation
102
+ # @api private
103
+ def apply_default_scope(relation, value)
104
+ case type
105
+ when :text
106
+ relation.where("#{key} LIKE ?", "%#{value}%")
107
+ when :select, :boolean
108
+ relation.where(key => value)
109
+ when :date_range
110
+ from = value[:from]
111
+ to = value[:to]
112
+ scope = relation
113
+ scope = scope.where("#{key} >= ?", from) if from.present?
114
+ scope = scope.where("#{key} <= ?", to) if to.present?
115
+ scope
116
+ when :number_range
117
+ min = value[:min]
118
+ max = value[:max]
119
+ scope = relation
120
+ scope = scope.where("#{key} >= ?", min) if min.present?
121
+ scope = scope.where("#{key} <= ?", max) if max.present?
122
+ scope
123
+ else
124
+ relation.where(key => value)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Represents a form field definition for admin resource forms.
6
+ #
7
+ # FormFieldDefinition is an immutable data structure that describes how a form
8
+ # field should be rendered and validated in new/edit forms. It handles field
9
+ # type, options, validation, hints, and visibility rules.
10
+ #
11
+ # @!attribute [r] key
12
+ # @return [Symbol] the attribute name for this field
13
+ # @!attribute [r] label
14
+ # @return [String] the human-readable field label
15
+ # @!attribute [r] type
16
+ # @return [Symbol] the field type (:text, :textarea, :select, :checkbox, :number, :email, :password, :datetime, :hidden, :json)
17
+ # @!attribute [r] options
18
+ # @return [Array, Proc, nil] options for select-type fields (array or callable)
19
+ # @!attribute [r] required
20
+ # @return [Boolean] whether the field is required
21
+ # @!attribute [r] hint
22
+ # @return [String, nil] optional help text displayed below the field
23
+ # @!attribute [r] visible_on
24
+ # @return [Array<Symbol>] contexts where this field is visible (:new, :edit)
25
+ #
26
+ # @example Building a simple text field
27
+ # field = FormFieldDefinition.build(:name)
28
+ # field.key #=> :name
29
+ # field.label #=> "Name"
30
+ # field.type #=> :text
31
+ # field.required #=> false
32
+ #
33
+ # @example Building a required email field
34
+ # field = FormFieldDefinition.build(:email,
35
+ # type: :email,
36
+ # required: true,
37
+ # hint: "We'll never share your email"
38
+ # )
39
+ #
40
+ # @example Building a select field with options
41
+ # field = FormFieldDefinition.build(:role,
42
+ # type: :select,
43
+ # options: %w[admin user guest],
44
+ # required: true
45
+ # )
46
+ FormFieldDefinition = Data.define(
47
+ :key, # Symbol
48
+ :label, # String
49
+ :type, # Symbol — :text, :textarea, :select, :checkbox, :number, :email, :password, :datetime, :hidden, :json
50
+ :options, # Array | Proc | nil
51
+ :required, # Boolean
52
+ :hint, # String | nil
53
+ :visible_on # Array<Symbol>
54
+ )
55
+
56
+ class FormFieldDefinition
57
+ # Build a FormFieldDefinition with smart defaults.
58
+ #
59
+ # @param key [Symbol, String] the attribute name
60
+ # @param label [String, nil] the display label (defaults to humanized key)
61
+ # @param type [Symbol] the field type (default: :text)
62
+ # @param options [Array, Proc, nil] options for select-type fields
63
+ # @param required [Boolean] whether the field is required (default: false)
64
+ # @param hint [String, nil] optional help text
65
+ # @param visible_on [Symbol, Array<Symbol>] contexts where visible (default: [:new, :edit])
66
+ # @return [FormFieldDefinition] a frozen, immutable form field definition
67
+ #
68
+ # @example
69
+ # FormFieldDefinition.build(:description, type: :textarea, required: true)
70
+ def self.build(key, label: nil, type: :text, options: nil, required: false, hint: nil, visible_on: %i[new edit])
71
+ new(
72
+ key: key.to_sym,
73
+ label: label || key.to_s.humanize,
74
+ type: type.to_sym,
75
+ options: options,
76
+ required: required,
77
+ hint: hint,
78
+ visible_on: Array(visible_on).map(&:to_sym)
79
+ )
80
+ end
81
+
82
+ # Check if this field is visible in a given context.
83
+ #
84
+ # @param context [Symbol, String] the rendering context (:new or :edit)
85
+ # @return [Boolean] true if the field should be displayed in this context
86
+ #
87
+ # @example
88
+ # field = FormFieldDefinition.build(:password, visible_on: [:new])
89
+ # field.visible_on?(:new) #=> true
90
+ # field.visible_on?(:edit) #=> false
91
+ def visible_on?(context)
92
+ visible_on.include?(context.to_sym)
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RSB
4
+ module Admin
5
+ # Lucide icon system for the admin panel.
6
+ #
7
+ # Provides a curated subset of 30 Lucide icons as inline SVG strings.
8
+ # All icons use currentColor for stroke, allowing them to inherit text color.
9
+ #
10
+ # @example Render an icon
11
+ # RSB::Admin::Icons.render("users") # => "<svg ...>...</svg>"
12
+ # RSB::Admin::Icons.render("users", size: 24) # => "<svg width=\"24\" height=\"24\" ...>...</svg>"
13
+ #
14
+ # @example Unknown icon returns empty string (no error)
15
+ # RSB::Admin::Icons.render("nonexistent") # => ""
16
+ #
17
+ # @example List all available icons
18
+ # RSB::Admin::Icons::ICONS.keys # => ["home", "users", "mail", ...]
19
+ module Icons
20
+ # Hash of icon names to SVG markup strings.
21
+ # Each SVG uses the placeholder "SIZE" for width/height values.
22
+ # All SVGs use currentColor for stroke to inherit text color.
23
+ #
24
+ # Available icons: alert-circle, alert-triangle, arrow-down, arrow-up, bar-chart,
25
+ # check-circle, chevron-left, chevron-right, credit-card, edit, eye, filter, home,
26
+ # info, key, layout-dashboard, lock, log-out, mail, monitor, moon, plus, receipt,
27
+ # search, settings, shield, sun, trash, users, users-cog, x
28
+ ICONS = {
29
+ 'home' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
30
+ 'users' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
31
+ 'mail' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>',
32
+ 'monitor' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>',
33
+ 'smartphone' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>',
34
+ 'tablet' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="20" x="4" y="2" rx="2" ry="2"/><line x1="12" x2="12.01" y1="18" y2="18"/></svg>',
35
+ 'credit-card' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>',
36
+ 'shield' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>',
37
+ 'bar-chart' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" x2="12" y1="20" y2="10"/><line x1="18" x2="18" y1="20" y2="4"/><line x1="6" x2="6" y1="20" y2="16"/></svg>',
38
+ 'settings' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>',
39
+ 'users-cog' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><circle cx="19" cy="11" r="2"/><path d="M19 8v1"/><path d="M19 13v1"/><path d="m21.6 9.5-.87.5"/><path d="m17.27 12-.87.5"/><path d="m21.6 12.5-.87-.5"/><path d="m17.27 10-.87-.5"/></svg>',
40
+ 'key' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/></svg>',
41
+ 'lock' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
42
+ 'chevron-right' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>',
43
+ 'chevron-left' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>',
44
+ 'search' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>',
45
+ 'filter' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>',
46
+ 'globe' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>',
47
+ 'plus' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>',
48
+ 'edit' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg>',
49
+ 'trash' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>',
50
+ 'eye' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>',
51
+ 'log-out' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>',
52
+ 'arrow-up' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>',
53
+ 'arrow-down' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>',
54
+ 'x' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
55
+ 'alert-circle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>',
56
+ 'alert-triangle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
57
+ 'check-circle' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>',
58
+ 'info' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>',
59
+ 'layout-dashboard' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg>',
60
+ 'sun' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>',
61
+ 'moon' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>',
62
+ 'receipt' => '<svg xmlns="http://www.w3.org/2000/svg" width="SIZE" height="SIZE" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z"/><path d="M14 8H8"/><path d="M16 12H8"/><path d="M13 16H8"/></svg>'
63
+ }.freeze
64
+
65
+ # Render an icon SVG string with specified dimensions.
66
+ #
67
+ # Looks up the icon by name in the ICONS hash and replaces the "SIZE"
68
+ # placeholder with the requested pixel size. If the icon is not found,
69
+ # returns an empty string (no error is raised - rule #9).
70
+ #
71
+ # @param name [String, Symbol] The icon name (e.g., "users", :home)
72
+ # @param size [Integer] The width and height of the icon in pixels
73
+ #
74
+ # @return [ActiveSupport::SafeBuffer] HTML-safe SVG string, or empty string if icon not found
75
+ #
76
+ # @example Render a users icon at default size
77
+ # RSB::Admin::Icons.render("users")
78
+ # # => '<svg xmlns="..." width="18" height="18" ...>...</svg>'
79
+ #
80
+ # @example Render a home icon at custom size
81
+ # RSB::Admin::Icons.render(:home, size: 32)
82
+ # # => '<svg xmlns="..." width="32" height="32" ...>...</svg>'
83
+ #
84
+ # @example Unknown icon returns empty string
85
+ # RSB::Admin::Icons.render("unknown")
86
+ # # => ""
87
+ def self.render(name, size: 18)
88
+ svg = ICONS[name.to_s]
89
+ return ''.html_safe unless svg
90
+
91
+ svg.gsub('SIZE', size.to_s).html_safe
92
+ end
93
+ end
94
+ end
95
+ end