plutonium 0.42.0 → 0.43.0

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -1
  3. data/.claude/skills/plutonium-definition/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-forms/SKILL.md +16 -1
  5. data/.claude/skills/plutonium-profile/SKILL.md +276 -0
  6. data/.claude/skills/plutonium-views/SKILL.md +23 -1
  7. data/CHANGELOG.md +36 -0
  8. data/app/assets/plutonium.css +1 -1
  9. data/app/views/plutonium/_resource_header.html.erb +6 -27
  10. data/app/views/plutonium/_resource_sidebar.html.erb +1 -2
  11. data/app/views/resource/_resource_details.rabl +3 -2
  12. data/app/views/resource/index.rabl +3 -2
  13. data/app/views/resource/show.rabl +3 -2
  14. data/docs/guides/user-profile.md +322 -0
  15. data/docs/reference/controller/index.md +38 -1
  16. data/docs/reference/definition/index.md +16 -0
  17. data/docs/reference/views/forms.md +15 -0
  18. data/docs/reference/views/index.md +23 -1
  19. data/gemfiles/rails_7.gemfile.lock +1 -1
  20. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  21. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  22. data/lib/generators/pu/core/assets/assets_generator.rb +12 -0
  23. data/lib/generators/pu/core/install/templates/app/controllers/resource_controller.rb.tt +11 -0
  24. data/lib/generators/pu/core/typespec/templates/common.tsp.tt +95 -0
  25. data/lib/generators/pu/core/typespec/templates/main.tsp.tt +27 -0
  26. data/lib/generators/pu/core/typespec/templates/main_multi.tsp.tt +25 -0
  27. data/lib/generators/pu/core/typespec/templates/model.tsp.tt +226 -0
  28. data/lib/generators/pu/core/typespec/typespec_generator.rb +342 -0
  29. data/lib/generators/pu/invites/USAGE +0 -1
  30. data/lib/generators/pu/invites/install_generator.rb +62 -15
  31. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +2 -2
  32. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +2 -0
  33. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +1 -0
  34. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +5 -5
  35. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +4 -4
  36. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +1 -1
  37. data/lib/generators/pu/lib/plutonium_generators/generator.rb +29 -0
  38. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +6 -23
  39. data/lib/generators/pu/pkg/portal/portal_generator.rb +5 -1
  40. data/lib/generators/pu/profile/USAGE +59 -0
  41. data/lib/generators/pu/profile/concerns/profile_arguments.rb +27 -0
  42. data/lib/generators/pu/profile/conn/USAGE +33 -0
  43. data/lib/generators/pu/profile/conn_generator.rb +167 -0
  44. data/lib/generators/pu/profile/install_generator.rb +119 -0
  45. data/lib/generators/pu/profile/setup/USAGE +42 -0
  46. data/lib/generators/pu/profile/setup_generator.rb +73 -0
  47. data/lib/generators/pu/rodauth/account_generator.rb +2 -4
  48. data/lib/generators/pu/rodauth/install_generator.rb +2 -2
  49. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +3 -0
  50. data/lib/generators/pu/saas/api_client_generator.rb +0 -2
  51. data/lib/generators/pu/saas/membership_generator.rb +68 -19
  52. data/lib/generators/pu/saas/setup_generator.rb +7 -2
  53. data/lib/generators/pu/saas/user_generator.rb +0 -2
  54. data/lib/plutonium/auth/rodauth.rb +8 -0
  55. data/lib/plutonium/core/controller.rb +7 -4
  56. data/lib/plutonium/core/controllers/authorizable.rb +5 -1
  57. data/lib/plutonium/definition/base.rb +7 -0
  58. data/lib/plutonium/helpers/display_helper.rb +6 -0
  59. data/lib/plutonium/profile/security_section.rb +118 -0
  60. data/lib/plutonium/resource/controller.rb +17 -7
  61. data/lib/plutonium/resource/controllers/interactive_actions.rb +11 -25
  62. data/lib/plutonium/resource/controllers/presentable.rb +46 -3
  63. data/lib/plutonium/resource/record/associated_with.rb +7 -1
  64. data/lib/plutonium/routing/mapper_extensions.rb +18 -18
  65. data/lib/plutonium/routing/route_set_extensions.rb +23 -2
  66. data/lib/plutonium/ui/breadcrumbs.rb +111 -131
  67. data/lib/plutonium/ui/dyna_frame/content.rb +12 -2
  68. data/lib/plutonium/ui/form/resource.rb +26 -19
  69. data/lib/plutonium/ui/page/base.rb +14 -14
  70. data/lib/plutonium/ui/table/components/selection_column.rb +6 -2
  71. data/lib/plutonium/ui/table/resource.rb +3 -2
  72. data/lib/plutonium/version.rb +1 -1
  73. data/package.json +1 -1
  74. metadata +17 -3
  75. data/lib/generators/pu/rodauth/concerns/gem_helpers.rb +0 -19
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Profile
5
+ # Renders security settings links based on enabled Rodauth features.
6
+ class SecuritySection < Plutonium::UI::Component::Base
7
+ FEATURES = {
8
+ change_password: {
9
+ label: "Change Password",
10
+ description: "Update your account password",
11
+ icon: Phlex::TablerIcons::Key,
12
+ path_method: :change_password_path
13
+ },
14
+ change_login: {
15
+ label: "Change Email",
16
+ description: "Update your email address",
17
+ icon: Phlex::TablerIcons::Mail,
18
+ path_method: :change_login_path
19
+ },
20
+ otp: {
21
+ label: "Two-Factor Authentication",
22
+ description: "Add an extra layer of security",
23
+ icon: Phlex::TablerIcons::DeviceMobile,
24
+ path_method: :otp_setup_path
25
+ },
26
+ recovery_codes: {
27
+ label: "Recovery Codes",
28
+ description: "View or regenerate backup codes",
29
+ icon: Phlex::TablerIcons::FileCode,
30
+ path_method: :recovery_codes_path
31
+ },
32
+ webauthn: {
33
+ label: "Security Keys",
34
+ description: "Manage passkeys and security keys",
35
+ icon: Phlex::TablerIcons::Fingerprint,
36
+ path_method: :webauthn_setup_path
37
+ },
38
+ active_sessions: {
39
+ label: "Active Sessions",
40
+ description: "View and manage your sessions",
41
+ icon: Phlex::TablerIcons::DevicesCheck,
42
+ path_method: :active_sessions_path
43
+ },
44
+ close_account: {
45
+ label: "Close Account",
46
+ description: "Permanently delete your account",
47
+ icon: Phlex::TablerIcons::Trash,
48
+ path_method: :close_account_path,
49
+ danger: true
50
+ }
51
+ }.freeze
52
+
53
+ def view_template
54
+ div(class: "mt-8") do
55
+ render_section_header
56
+ render_feature_links
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def render_section_header
63
+ div(class: "mb-4") do
64
+ h2(class: "text-lg font-semibold text-[var(--pu-text)]") { "Security Settings" }
65
+ p(class: "text-sm text-[var(--pu-text-muted)]") { "Manage your account security" }
66
+ end
67
+ end
68
+
69
+ def render_feature_links
70
+ div(
71
+ class: "bg-[var(--pu-card-bg)] border border-[var(--pu-card-border)] rounded-[var(--pu-radius-lg)] divide-y divide-[var(--pu-border)]",
72
+ style: "box-shadow: var(--pu-shadow-sm)"
73
+ ) do
74
+ enabled_features.each do |feature, config|
75
+ render_feature_link(feature, config)
76
+ end
77
+ end
78
+ end
79
+
80
+ def render_feature_link(feature, config)
81
+ path = helpers.rodauth.send(config[:path_method])
82
+ danger = config[:danger]
83
+
84
+ a(
85
+ href: path,
86
+ class: tokens(
87
+ "flex items-center gap-4 p-4 hover:bg-[var(--pu-surface-alt)] transition-colors first:rounded-t-[var(--pu-radius-lg)] last:rounded-b-[var(--pu-radius-lg)]",
88
+ danger ? "text-[var(--pu-text-danger)]" : "text-[var(--pu-text)]"
89
+ )
90
+ ) do
91
+ # Icon
92
+ div(class: "flex-shrink-0") do
93
+ render config[:icon].new(class: "w-5 h-5")
94
+ end
95
+
96
+ # Content
97
+ div(class: "flex-grow") do
98
+ div(class: "font-medium") { config[:label] }
99
+ div(class: "text-sm text-[var(--pu-text-muted)]") { config[:description] }
100
+ end
101
+
102
+ # Arrow
103
+ div(class: "flex-shrink-0 text-[var(--pu-text-muted)]") do
104
+ render Phlex::TablerIcons::ChevronRight.new(class: "w-5 h-5")
105
+ end
106
+ end
107
+ end
108
+
109
+ def enabled_features
110
+ FEATURES.select { |feature, _config| feature_enabled?(feature) }
111
+ end
112
+
113
+ def feature_enabled?(feature)
114
+ helpers.rodauth.features.include?(feature)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -104,6 +104,13 @@ module Plutonium
104
104
  end
105
105
  end
106
106
 
107
+ # Returns true if current resource is registered as a singular route
108
+ # (e.g., `resource :profile` vs `resources :users`)
109
+ # @return [Boolean]
110
+ def singular_resource_context?
111
+ current_resource_route_config&.[](:route_type) == :resource
112
+ end
113
+
107
114
  # Extracts the association name from the current nested route
108
115
  # e.g., for route /posts/:post_id/nested_comments, returns :comments
109
116
  # @return [Symbol, nil] The association name
@@ -242,14 +249,17 @@ module Plutonium
242
249
  # Overrides entity scoping parameters
243
250
  # @param [Hash] input_params The input parameters
244
251
  def override_entity_scoping_params(input_params)
245
- if scoped_to_entity?
246
- if input_params.key?(scoped_entity_param_key) || resource_class.method_defined?(:"#{scoped_entity_param_key}=")
247
- input_params[scoped_entity_param_key] = current_scoped_entity
248
- end
252
+ return unless scoped_to_entity?
249
253
 
250
- if input_params.key?(:"#{scoped_entity_param_key}_id") || resource_class.method_defined?(:"#{scoped_entity_param_key}_id=")
251
- input_params[:"#{scoped_entity_param_key}_id"] = current_scoped_entity.id
252
- end
254
+ # Use the detected association if available, otherwise fall back to param_key
255
+ assoc_name = scoped_entity_association || scoped_entity_param_key
256
+
257
+ if input_params.key?(assoc_name) || resource_class.method_defined?(:"#{assoc_name}=")
258
+ input_params[assoc_name] = current_scoped_entity
259
+ end
260
+
261
+ if input_params.key?(:"#{assoc_name}_id") || resource_class.method_defined?(:"#{assoc_name}_id=")
262
+ input_params[:"#{assoc_name}_id"] = current_scoped_entity.id
253
263
  end
254
264
  end
255
265
 
@@ -29,12 +29,7 @@ module Plutonium
29
29
  # GET /resources/1/record_actions/:interactive_action
30
30
  def interactive_record_action
31
31
  build_interactive_record_action_interaction
32
-
33
- if helpers.current_turbo_frame == "remote_modal"
34
- render layout: false, formats: [:html]
35
- else
36
- render :interactive_record_action, formats: [:html]
37
- end
32
+ render :interactive_record_action, layout: modal_layout, formats: [:html]
38
33
  end
39
34
 
40
35
  # POST /resources/1/record_actions/:interactive_action
@@ -67,7 +62,7 @@ module Plutonium
67
62
  end
68
63
  else
69
64
  format.any(:html, :turbo_stream) do
70
- render :interactive_record_action, formats: [:html], status: :unprocessable_content
65
+ render :interactive_record_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
71
66
  end
72
67
  format.any do
73
68
  @errors = @interaction.errors
@@ -82,16 +77,7 @@ module Plutonium
82
77
  def interactive_resource_action
83
78
  skip_verify_current_authorized_scope!
84
79
  build_interactive_resource_action_interaction
85
-
86
- respond_to do |format|
87
- format.any(:html, :turbo_stream) do
88
- if helpers.current_turbo_frame == "remote_modal"
89
- render layout: false, formats: [:html]
90
- else
91
- render :interactive_resource_action, formats: [:html]
92
- end
93
- end
94
- end
80
+ render :interactive_resource_action, layout: modal_layout, formats: [:html]
95
81
  end
96
82
 
97
83
  # POST /resources/resource_actions/:interactive_action
@@ -125,7 +111,7 @@ module Plutonium
125
111
  end
126
112
  else
127
113
  format.any(:html, :turbo_stream) do
128
- render :interactive_resource_action, formats: [:html], status: :unprocessable_content
114
+ render :interactive_resource_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
129
115
  end
130
116
  format.any do
131
117
  @errors = @interaction.errors
@@ -139,12 +125,7 @@ module Plutonium
139
125
  # GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
140
126
  def interactive_bulk_action
141
127
  build_interactive_bulk_action_interaction
142
-
143
- if helpers.current_turbo_frame == "remote_modal"
144
- render layout: false, formats: [:html]
145
- else
146
- render :interactive_bulk_action, formats: [:html]
147
- end
128
+ render :interactive_bulk_action, layout: modal_layout, formats: [:html]
148
129
  end
149
130
 
150
131
  # POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
@@ -177,7 +158,7 @@ module Plutonium
177
158
  end
178
159
  else
179
160
  format.any(:html, :turbo_stream) do
180
- render :interactive_bulk_action, formats: [:html], status: :unprocessable_content
161
+ render :interactive_bulk_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
181
162
  end
182
163
  format.any do
183
164
  @errors = @interaction.errors
@@ -190,6 +171,11 @@ module Plutonium
190
171
 
191
172
  private
192
173
 
174
+ # Returns false for modal requests (skip layout), nil otherwise (use default layout)
175
+ def modal_layout
176
+ helpers.current_turbo_frame.present? ? false : nil
177
+ end
178
+
193
179
  def current_interactive_action
194
180
  @current_interactive_action = interactive_resource_actions[params[:interactive_action].to_sym]
195
181
  end
@@ -17,7 +17,7 @@ module Plutonium
17
17
  presentable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
18
18
  end
19
19
  if scoped_to_entity? && !present_scoped_entity?
20
- presentable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
20
+ presentable_attributes -= scoped_entity_field_names
21
21
  end
22
22
  presentable_attributes
23
23
  end
@@ -33,11 +33,49 @@ module Plutonium
33
33
  submittable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
34
34
  end
35
35
  if scoped_to_entity? && !submit_scoped_entity?
36
- submittable_attributes -= [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
36
+ submittable_attributes -= scoped_entity_field_names
37
37
  end
38
38
  submittable_attributes
39
39
  end
40
40
 
41
+ # Returns all field names related to the scoped entity.
42
+ # Finds associations by class to handle cases where param_key differs from association name.
43
+ def scoped_entity_field_names
44
+ field_names = [scoped_entity_param_key, :"#{scoped_entity_param_key}_id"]
45
+
46
+ assoc_name = scoped_entity_association
47
+ if assoc_name
48
+ field_names << assoc_name
49
+ field_names << :"#{assoc_name}_id"
50
+ end
51
+
52
+ field_names.uniq
53
+ end
54
+
55
+ # Returns the name of the belongs_to association pointing to the scoped entity class.
56
+ # Raises if multiple associations exist (ambiguous - user must configure manually).
57
+ # @return [Symbol, nil] the association name or nil if not found
58
+ def scoped_entity_association
59
+ return @scoped_entity_association if defined?(@scoped_entity_association)
60
+
61
+ matching_assocs = resource_class.reflect_on_all_associations(:belongs_to).select do |assoc|
62
+ assoc.klass.name == scoped_entity_class.name
63
+ rescue NameError
64
+ false
65
+ end
66
+
67
+ if matching_assocs.size > 1
68
+ assoc_names = matching_assocs.map(&:name).join(", ")
69
+ raise <<~MSG.squish
70
+ #{resource_class} has multiple associations to #{scoped_entity_class}: #{assoc_names}.
71
+ Plutonium cannot auto-detect which one to use for entity scoping.
72
+ Override `scoped_entity_association` in your controller to specify the association.
73
+ MSG
74
+ end
75
+
76
+ @scoped_entity_association = matching_assocs.first&.name
77
+ end
78
+
41
79
  def build_collection
42
80
  current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
43
81
  end
@@ -47,7 +85,12 @@ module Plutonium
47
85
  end
48
86
 
49
87
  def build_form(record = resource_record!, action: action_name, form_action: nil, **)
50
- form_options = {resource_fields: submittable_attributes_for(action), resource_definition: current_definition, **}
88
+ form_options = {
89
+ resource_fields: submittable_attributes_for(action),
90
+ resource_definition: current_definition,
91
+ singular_resource: singular_resource_context?,
92
+ **
93
+ }
51
94
  form_options[:action] = form_action unless form_action.nil?
52
95
  current_definition.form_class.new(record, **form_options)
53
96
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # lib/plutonium/resource/associations.rb
4
3
  module Plutonium
5
4
  module Resource
6
5
  module Record
@@ -9,6 +8,13 @@ module Plutonium
9
8
 
10
9
  included do
11
10
  scope :associated_with, ->(record) do
11
+ # If scoping to same class, just match by ID (e.g., Team scoped to Team)
12
+ # Compare by name to handle Rails class reloading (different object_id after reload)
13
+ if klass.name == record.class.name
14
+ pk = klass.primary_key
15
+ return where(pk => record.public_send(pk))
16
+ end
17
+
12
18
  named_scope = :"associated_with_#{record.model_name.singular}"
13
19
  return send(named_scope, record) if respond_to?(named_scope)
14
20
 
@@ -44,16 +44,28 @@ module Plutonium
44
44
  end
45
45
  end
46
46
 
47
+ # Draws routes wrapped in entity scope when using path-based entity scoping.
48
+ #
49
+ # @param scope_params [Hash, nil] Scope params from RouteSetExtensions, or nil if no scoping
50
+ # @param block [Proc] The block containing route definitions.
51
+ # @return [void]
52
+ def draw_routes_with_entity_scope(scope_params, &block)
53
+ if scope_params
54
+ scope scope_params[:name], **scope_params[:options] do
55
+ instance_exec(&block)
56
+ materialize_resource_routes
57
+ end
58
+ else
59
+ instance_exec(&block)
60
+ materialize_resource_routes
61
+ end
62
+ end
63
+
47
64
  # Materializes all registered resource routes.
48
65
  #
49
66
  # @return [void]
50
67
  def materialize_resource_routes
51
- engine = route_set.engine
52
- scope_params = determine_scope_params(engine)
53
-
54
- scope scope_params[:name], scope_params[:options] do
55
- concerns resource_route_concern_names.sort
56
- end
68
+ concerns resource_route_concern_names.sort
57
69
  end
58
70
 
59
71
  # @return [ActionDispatch::Routing::RouteSet] The current route set.
@@ -149,18 +161,6 @@ module Plutonium
149
161
  as: :commit_interactive_resource_action
150
162
  end
151
163
  end
152
-
153
- # Determines the scope parameters based on the engine configuration.
154
- #
155
- # @param engine [Class] The current engine.
156
- # @return [Hash] Scope name and options.
157
- def determine_scope_params(engine)
158
- scoped_entity_param_key = engine.scoped_entity_param_key if engine.scoped_entity_strategy == :path
159
- {
160
- name: scoped_entity_param_key.present? ? ":#{scoped_entity_param_key}" : "",
161
- options: scoped_entity_param_key.present? ? {as: scoped_entity_param_key} : {}
162
- }
163
- end
164
164
  end
165
165
  end
166
166
  end
@@ -34,11 +34,11 @@ module Plutonium
34
34
  # @yield Executes the given block in the context of route drawing.
35
35
  def draw(&block)
36
36
  if self.class.supported_engine?(engine)
37
+ scope_params = entity_scope_params_for_path_strategy
37
38
  ActiveSupport::Notifications.instrument("plutonium.resource_routes.draw", app: engine.to_s) do
38
39
  super do
39
40
  setup_shared_resource_concerns
40
- instance_exec(&block)
41
- materialize_resource_routes
41
+ draw_routes_with_entity_scope(scope_params, &block)
42
42
  end
43
43
  end
44
44
  else
@@ -46,6 +46,19 @@ module Plutonium
46
46
  end
47
47
  end
48
48
 
49
+ # Determines entity scope parameters for path-based scoping.
50
+ #
51
+ # @return [Hash, nil] Scope params if path-based scoping is enabled, nil otherwise
52
+ def entity_scope_params_for_path_strategy
53
+ return nil unless engine.scoped_entity_strategy == :path
54
+
55
+ param_key = engine.scoped_entity_param_key
56
+ {
57
+ name: ":#{param_key}",
58
+ options: {as: param_key}
59
+ }
60
+ end
61
+
49
62
  # Registers a resource for routing.
50
63
  #
51
64
  # @param resource [Class] The resource class to be registered.
@@ -75,6 +88,14 @@ module Plutonium
75
88
  resource_route_config_lookup.slice(*routes).values
76
89
  end
77
90
 
91
+ # Checks if a resource is registered as a singular route.
92
+ #
93
+ # @param route_key [String] The route key (e.g., "users" or "users/profiles")
94
+ # @return [Boolean] true if the resource is a singular route, false otherwise
95
+ def singular_resource_route?(route_key)
96
+ resource_route_config_for(route_key)[0]&.[](:route_type) == :resource
97
+ end
98
+
78
99
  # Returns the current engine for the routes.
79
100
  #
80
101
  # @return [Class] The engine class (Rails application or custom engine).