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
@@ -23,15 +23,15 @@ module Pu
23
23
  class_option :membership_model, type: :string,
24
24
  desc: "The membership model name (defaults to <Entity>User)"
25
25
 
26
- class_option :roles, type: :array, default: %w[member admin],
27
- desc: "Available roles for invitations"
28
-
29
26
  class_option :rodauth, type: :string, default: "user",
30
27
  desc: "Rodauth configuration name for signup integration"
31
28
 
32
29
  class_option :enforce_domain, type: :boolean, default: false,
33
30
  desc: "Require invited user email to match entity domain"
34
31
 
32
+ class_option :dest, type: :string, default: "main_app",
33
+ desc: "Package where entity model is located (default: main_app)"
34
+
35
35
  def validate_requirements
36
36
  errors = []
37
37
 
@@ -55,9 +55,13 @@ module Pu
55
55
  errors << "User policy not found: #{user_policy_path}"
56
56
  end
57
57
 
58
+ unless File.exist?(membership_model_file)
59
+ errors << "Membership model not found: #{membership_model_file.relative_path_from(Rails.root)}"
60
+ end
61
+
58
62
  if errors.any?
59
63
  errors.each { |e| say_status :error, e, :red }
60
- raise Thor::Error, "Required files missing. Ensure #{entity_model} and #{user_model} resources exist."
64
+ raise Thor::Error, "Required files missing:\n - #{errors.join("\n - ")}"
61
65
  end
62
66
  end
63
67
 
@@ -132,8 +136,13 @@ module Pu
132
136
  end
133
137
 
134
138
  def create_entity_interaction
135
- template "app/interactions/invite_user_interaction.rb",
139
+ dest_path = if entity_in_package?
140
+ "packages/#{entity_package}/app/interactions/#{entity_table}/invite_user_interaction.rb"
141
+ else
136
142
  "app/interactions/#{entity_table}/invite_user_interaction.rb"
143
+ end
144
+
145
+ template "app/interactions/invite_user_interaction.rb", dest_path
137
146
  end
138
147
 
139
148
  def add_entity_action
@@ -144,7 +153,7 @@ module Pu
144
153
 
145
154
  def add_entity_policy
146
155
  inject_into_file entity_policy_path,
147
- "def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
156
+ "def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
148
157
  before: "# Core attributes"
149
158
  end
150
159
 
@@ -161,7 +170,7 @@ module Pu
161
170
 
162
171
  def add_user_policy
163
172
  inject_into_file user_policy_path,
164
- "def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
173
+ "def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
165
174
  before: "# Core attributes"
166
175
  end
167
176
 
@@ -176,7 +185,7 @@ module Pu
176
185
  def current_membership
177
186
  return unless entity_scope && user
178
187
 
179
- cache(entity_scope, user, :current_membership) { #{membership_model}.find_by(#{entity_table}: entity_scope, user: user) }
188
+ @current_membership ||= #{membership_model}.find_by(#{entity_association_name}: entity_scope, user: user)
180
189
  end
181
190
  RUBY
182
191
 
@@ -308,16 +317,43 @@ module Pu
308
317
  options[:entity_model].underscore
309
318
  end
310
319
 
320
+ # Returns the association name for entity on the membership model.
321
+ # Strips shared namespace between membership and entity models.
322
+ # e.g., Competition::TeamUser -> Competition::Team uses :team (not :competition_team)
323
+ def entity_association_name
324
+ PlutoniumGenerators::Generator.derive_association_name(membership_model, entity_model)
325
+ end
326
+
327
+ def entity_in_package?
328
+ options[:dest] != "main_app"
329
+ end
330
+
331
+ def entity_package
332
+ options[:dest]
333
+ end
334
+
311
335
  def entity_model_path
312
- "app/models/#{entity_table}.rb"
336
+ if entity_in_package?
337
+ "packages/#{entity_package}/app/models/#{entity_table}.rb"
338
+ else
339
+ "app/models/#{entity_table}.rb"
340
+ end
313
341
  end
314
342
 
315
343
  def entity_definition_path
316
- "app/definitions/#{entity_table}_definition.rb"
344
+ if entity_in_package?
345
+ "packages/#{entity_package}/app/definitions/#{entity_table}_definition.rb"
346
+ else
347
+ "app/definitions/#{entity_table}_definition.rb"
348
+ end
317
349
  end
318
350
 
319
351
  def entity_policy_path
320
- "app/policies/#{entity_table}_policy.rb"
352
+ if entity_in_package?
353
+ "packages/#{entity_package}/app/policies/#{entity_table}_policy.rb"
354
+ else
355
+ "app/policies/#{entity_table}_policy.rb"
356
+ end
321
357
  end
322
358
 
323
359
  def user_model
@@ -340,12 +376,23 @@ module Pu
340
376
  options[:membership_model] || "#{entity_model}User"
341
377
  end
342
378
 
343
- def roles
344
- Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
379
+ def membership_model_file
380
+ model_path = "#{membership_model.underscore}.rb"
381
+ if entity_in_package?
382
+ Rails.root.join("packages", options[:dest], "app/models", model_path)
383
+ else
384
+ Rails.root.join("app/models", model_path)
385
+ end
345
386
  end
346
387
 
347
- def roles_enum
348
- roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
388
+ # Read roles from the membership model's enum definition
389
+ def roles
390
+ content = File.read(membership_model_file)
391
+ if (match = content.match(/enum\s+:role,\s*(.+?)(?:\n|$)/))
392
+ match[1].scan(/(\w+):/).flatten
393
+ else
394
+ raise Thor::Error, "Could not find 'enum :role' in #{membership_model_file.relative_path_from(Rails.root)}"
395
+ end
349
396
  end
350
397
 
351
398
  def rodauth_config
@@ -4,7 +4,7 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
4
4
  def change
5
5
  create_table :user_invites do |t|
6
6
  # Entity association
7
- t.belongs_to :<%= entity_table %>, null: false, foreign_key: true
7
+ t.belongs_to :<%= entity_association_name %>, null: false, foreign_key: true
8
8
 
9
9
  # Invitation details
10
10
  t.string :email, null: false
@@ -34,7 +34,7 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
34
34
  t.index :token, unique: true
35
35
 
36
36
  # Only one pending invite per email per entity
37
- t.index [:<%= entity_table %>_id, :email], unique: true, where: "state = 0",
37
+ t.index [:<%= entity_association_name %>_id, :email], unique: true, where: "state = 0",
38
38
  name: "index_user_invites_on_entity_email_pending"
39
39
 
40
40
  # Only one pending invite per invitable (when invitable is present)
@@ -4,7 +4,9 @@ module Invites
4
4
  class UserInvitationsController < ApplicationController
5
5
  include Plutonium::Invites::Controller
6
6
 
7
+ prepend_view_path Invites::Engine.root.join("app/views")
7
8
  layout "invites/invitation"
9
+ helper_method :login_path
8
10
 
9
11
  private
10
12
 
@@ -4,6 +4,7 @@ module Invites
4
4
  class WelcomeController < ApplicationController
5
5
  include Plutonium::Invites::PendingInviteCheck
6
6
 
7
+ prepend_view_path Invites::Engine.root.join("app/views")
7
8
  layout "invites/invitation"
8
9
 
9
10
  <% if rodauth? -%>
@@ -4,11 +4,11 @@ module Invites
4
4
  class UserInvite < Invites::ResourceRecord
5
5
  include Plutonium::Invites::Concerns::InviteToken
6
6
 
7
- enum :role, <%= roles_enum %>
7
+ enum :role, <%= membership_model %>.roles
8
8
 
9
9
  encrypts :token, deterministic: true
10
10
 
11
- belongs_to :<%= entity_table %>
11
+ belongs_to :<%= entity_association_name %>, class_name: "<%= entity_model %>"
12
12
  belongs_to :invited_by, polymorphic: true
13
13
  belongs_to :<%= user_table %>, optional: true
14
14
  belongs_to :invitable, polymorphic: true, optional: true
@@ -30,12 +30,12 @@ module Invites
30
30
  end
31
31
 
32
32
  def create_membership_for(user)
33
- <%= membership_model %>.create!(<%= entity_table %>: <%= entity_table %>, user: user, role: role)
33
+ <%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, user: user, role: role)
34
34
  end
35
- <% if entity_table != "entity" -%>
35
+ <% if entity_association_name != "entity" -%>
36
36
 
37
37
  # Alias for InviteToken concern compatibility
38
- alias_method :entity, :<%= entity_table %>
38
+ alias_method :entity, :<%= entity_association_name %>
39
39
  <% end -%>
40
40
  end
41
41
  end
@@ -15,13 +15,13 @@
15
15
  <label for="email" class="pu-label">Email</label>
16
16
  <%% if @invite.enforce_email? %>
17
17
  <input type="email" name="email" value="<%%= @invite.email %>" disabled
18
- class="pu-input cursor-not-allowed opacity-60">
18
+ autocomplete="email" class="pu-input cursor-not-allowed opacity-60">
19
19
  <input type="hidden" name="email" value="<%%= @invite.email %>">
20
20
  <p class="pu-hint">This email is required for your invitation</p>
21
21
  <%% else %>
22
22
  <%%= form.email_field :email, value: @invite.email, required: true,
23
23
  placeholder: "you@example.com",
24
- class: "pu-input" %>
24
+ autocomplete: "email", class: "pu-input" %>
25
25
  <%% if (domain = @invite.enforce_domain) %>
26
26
  <p class="pu-hint">Must be from <%%= domain %> domain</p>
27
27
  <%% else %>
@@ -32,12 +32,12 @@
32
32
 
33
33
  <div>
34
34
  <label for="password" class="pu-label">Password</label>
35
- <%%= form.password_field :password, required: true, class: "pu-input" %>
35
+ <%%= form.password_field :password, required: true, autocomplete: "new-password", class: "pu-input" %>
36
36
  </div>
37
37
 
38
38
  <div>
39
39
  <label for="password_confirmation" class="pu-label">Confirm Password</label>
40
- <%%= form.password_field :password_confirmation, required: true, class: "pu-input" %>
40
+ <%%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", class: "pu-input" %>
41
41
  </div>
42
42
 
43
43
  <div class="space-y-3 pt-4">
@@ -447,7 +447,7 @@ module PlutoniumGenerators
447
447
 
448
448
  def gem_in_bundle?(name)
449
449
  in_root do
450
- return true if File.exist?("Gemfile") && File.read("Gemfile").match?(/gem ['"]#{name}['"]/)
450
+ return true if File.exist?("Gemfile") && File.read("Gemfile").match?(/^\s*gem ['"]#{name}['"]/)
451
451
  return true if File.exist?("Gemfile.lock") && File.read("Gemfile.lock").include?(" #{name} ")
452
452
  end
453
453
  false
@@ -10,6 +10,35 @@ module PlutoniumGenerators
10
10
  include Concerns::Serializer
11
11
  include Concerns::Actions
12
12
 
13
+ # Finds the shared namespace prefix between two model names.
14
+ # Used to derive association names when models share a namespace.
15
+ # e.g., find_shared_namespace("Competition::TeamUser", "Competition::Team") => "competition"
16
+ def self.find_shared_namespace(model1, model2, separator: "/")
17
+ parts1 = model1.underscore.split(separator)
18
+ parts2 = model2.underscore.split(separator)
19
+
20
+ shared = []
21
+ [parts1.length, parts2.length].min.times do |i|
22
+ break unless parts1[i] == parts2[i]
23
+ shared << parts1[i]
24
+ end
25
+
26
+ shared.empty? ? nil : shared.join(separator)
27
+ end
28
+
29
+ # Derives the association name for a reference, stripping shared namespace.
30
+ # e.g., derive_association_name("Competition::TeamUser", "Competition::Team") => "team"
31
+ def self.derive_association_name(from_model, to_model)
32
+ to_parts = to_model.underscore.split("/")
33
+
34
+ if (shared = find_shared_namespace(from_model, to_model))
35
+ shared_parts = shared.split("/")
36
+ to_parts = to_parts.drop(shared_parts.length)
37
+ end
38
+
39
+ to_parts.join("_")
40
+ end
41
+
13
42
  def self.included(base)
14
43
  base.send :class_option, :interactive, type: :boolean, desc: "Show prompts. Default: true"
15
44
  base.send :class_option, :bundle, type: :boolean, desc: "Run bundle after setup. Default: true"
@@ -117,11 +117,7 @@ module PlutoniumGenerators
117
117
  if name.include? "/"
118
118
  attr_options[:to_table] = name.underscore.tr("/", "_").pluralize.to_sym
119
119
  attr_options[:class_name] = name.classify
120
- name = name.underscore
121
- if (shared_namespace = find_shared_namespace(model_name, name, separator: "/"))
122
- name = name.sub("#{shared_namespace}/", "")
123
- end
124
- name = name.tr("/", "_")
120
+ name = PlutoniumGenerators::Generator.derive_association_name(model_name, name)
125
121
  end
126
122
  end
127
123
 
@@ -259,27 +255,14 @@ module PlutoniumGenerators
259
255
  end
260
256
  end
261
257
 
262
- def find_shared_namespace(model1, model2, separator: "::")
263
- parts1 = model1.underscore.split(separator)
264
- parts2 = model2.underscore.split(separator)
265
-
266
- shared_namespace = []
267
- [parts1.length, parts2.length].min.times do |i|
268
- if parts1[i] == parts2[i]
269
- shared_namespace << parts1[i]
270
- else
271
- break
272
- end
273
- end
274
-
275
- shared_namespace.empty? ? nil : shared_namespace.join(separator)
276
- end
277
258
  end
278
259
 
279
260
  def required?
280
- return false if attr_options[:null] == true
281
-
282
- super
261
+ if attr_options.key?(:null)
262
+ !attr_options[:null]
263
+ else
264
+ super
265
+ end
283
266
  end
284
267
 
285
268
  def cents?
@@ -88,7 +88,11 @@ module Pu
88
88
  def bring_your_own_auth? = @bring_your_own_auth
89
89
 
90
90
  def configure_entity_scoping
91
- @scoped_entity_class = options[:scope].camelize if options[:scope].present?
91
+ return unless options[:scope].present?
92
+
93
+ scope = options[:scope].camelize
94
+ # Prepend :: to ensure absolute constant reference (avoids NameError in engine.rb)
95
+ @scoped_entity_class = scope.start_with?("::") ? scope : "::#{scope}"
92
96
  end
93
97
 
94
98
  def scoped_to_entity? = scoped_entity_class.present?
@@ -0,0 +1,59 @@
1
+ Description:
2
+ Generate a Profile resource for managing Rodauth account settings.
3
+ Creates a resource linked to the User model with a customized ShowPage
4
+ that displays security settings links (change password, 2FA, etc.).
5
+
6
+ Usage:
7
+ rails g pu:profile:install [NAME] [field:type field:type] --dest=DESTINATION
8
+
9
+ Arguments:
10
+ NAME Profile resource name (default: Profile)
11
+ field:type Additional fields to add to the profile
12
+
13
+ Options:
14
+ --dest=DESTINATION Target destination (required to avoid interactive prompts)
15
+ Use 'main_app' for main application
16
+ Use 'package_name' for feature package
17
+ --user-model=NAME Rodauth user model (default: User)
18
+
19
+ Examples:
20
+ # Basic profile in main_app
21
+ rails g pu:profile:install --dest=main_app
22
+
23
+ # Profile with custom fields
24
+ rails g pu:profile:install bio:text avatar:attachment --dest=main_app
25
+
26
+ # Custom name with fields in a package
27
+ rails g pu:profile:install AccountSettings \
28
+ bio:text \
29
+ 'timezone:string?' \
30
+ 'notifications_enabled:boolean' \
31
+ --dest=customer
32
+
33
+ # With custom user model
34
+ rails g pu:profile:install --dest=main_app --user-model=Account
35
+
36
+ Generated Files:
37
+ Model: app/models/[package/]profile.rb
38
+ Migration: db/migrate/xxx_create_[package_]profiles.rb
39
+ Controller: [packages/package/]app/controllers/[package/]profiles_controller.rb
40
+ Policy: [packages/package/]app/policies/[package/]profile_policy.rb
41
+ Definition: [packages/package/]app/definitions/[package/]profile_definition.rb
42
+
43
+ What Gets Modified:
44
+ User Model: Adds `has_one :profile, dependent: :destroy`
45
+ Definition: Injects ShowPage with SecuritySection for Rodauth links
46
+
47
+ The SecuritySection automatically displays links for enabled Rodauth features:
48
+ - Change Password (change_password)
49
+ - Change Email (change_login)
50
+ - Two-Factor Authentication (otp)
51
+ - Recovery Codes (recovery_codes)
52
+ - Security Keys (webauthn)
53
+ - Active Sessions (active_sessions)
54
+ - Close Account (close_account)
55
+
56
+ After Generation:
57
+ 1. Run migrations: rails db:migrate
58
+ 2. Connect to portal: rails g pu:profile:conn --dest=my_portal
59
+ 3. Customize the profile definition as needed
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pu
4
+ module Profile
5
+ module Concerns
6
+ module ProfileArguments
7
+ extend ActiveSupport::Concern
8
+
9
+ included do
10
+ argument :name, type: :string, default: "Profile", required: false, banner: "NAME"
11
+ argument :attributes, type: :array, default: [], banner: "field[:type] field[:type]"
12
+ end
13
+
14
+ # Normalize arguments: if name contains ":", treat it as an attribute
15
+ def normalize_arguments
16
+ if name.include?(":")
17
+ @profile_attributes = [name, *attributes]
18
+ @profile_name = "Profile"
19
+ else
20
+ @profile_name = name
21
+ @profile_attributes = attributes
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,33 @@
1
+ Description:
2
+ Connect a Profile resource to a portal with the profile_url helper configured.
3
+ This enables the "Profile" link in the user menu.
4
+
5
+ Usage:
6
+ rails g pu:profile:conn [RESOURCE] --dest=PORTAL
7
+
8
+ Arguments:
9
+ RESOURCE Profile resource class (default: Profile)
10
+ Use namespaced class for package resources (e.g., Competition::Profile)
11
+
12
+ Options:
13
+ --dest=PORTAL Target portal (required to avoid interactive prompts)
14
+
15
+ Examples:
16
+ # Connect namespaced Profile to portal
17
+ rails g pu:profile:conn Competition::Profile --dest=competition_portal
18
+
19
+ # Connect main_app Profile to portal
20
+ rails g pu:profile:conn Profile --dest=customer_portal
21
+
22
+ # Connect custom-named profile
23
+ rails g pu:profile:conn Competition::AccountSettings --dest=competition_portal
24
+
25
+ What Gets Created:
26
+ Controller: packages/[portal]/app/controllers/[portal]/profiles_controller.rb
27
+
28
+ What Gets Modified:
29
+ Routes: packages/[portal]/config/routes.rb (registers as singular resource)
30
+ Controller: packages/[portal]/app/controllers/[portal]/resource_controller.rb
31
+ (adds profile_url helper method)
32
+
33
+ The profile_url helper enables the "Profile" link in the user navigation menu.
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require_relative "../lib/plutonium_generators"
5
+
6
+ module Pu
7
+ module Profile
8
+ class ConnGenerator < ::Rails::Generators::Base
9
+ include PlutoniumGenerators::Generator
10
+
11
+ desc "Connect a Profile resource to a portal and configure the profile_url helper"
12
+
13
+ argument :name, type: :string, default: "Profile", required: false, banner: "RESOURCE"
14
+
15
+ class_option :dest, type: :string,
16
+ desc: "Destination portal"
17
+
18
+ class_option :user_model, type: :string, default: "User",
19
+ desc: "The Rodauth user model"
20
+
21
+ def start
22
+ validate_portal_destination!
23
+ connect_to_portal
24
+ customize_policy
25
+ customize_definition
26
+ customize_controller
27
+ add_profile_url_helper
28
+ rescue => e
29
+ exception "#{self.class} failed:", e
30
+ end
31
+
32
+ private
33
+
34
+ def validate_portal_destination!
35
+ if selected_destination_portal == "main_app"
36
+ raise ArgumentError, <<~MSG.squish
37
+ pu:profile:conn is for portal packages only. For main_app, configure
38
+ profile_url directly in your ResourceController.
39
+ MSG
40
+ end
41
+ end
42
+
43
+ def connect_to_portal
44
+ invoke "pu:res:conn", [resource_class_name],
45
+ dest: selected_destination_portal,
46
+ singular: true,
47
+ policy: true,
48
+ definition: true,
49
+ force: options[:force],
50
+ skip: options[:skip]
51
+ end
52
+
53
+ def customize_policy
54
+ content = <<-RUBY.chomp
55
+
56
+ # Profile is scoped to current user, not entity.
57
+ # Note: `user` here is the policy's user method (current authenticated user),
58
+ # while `#{user_table}` is the model's association name.
59
+ relation_scope do |relation|
60
+ skip_default_relation_scope!
61
+ relation.where(#{user_table}: user)
62
+ end
63
+
64
+ def create?
65
+ user.#{profile_association}.nil?
66
+ end
67
+
68
+ def destroy?
69
+ false
70
+ end
71
+
72
+ # User is set automatically from current_user, not via mass assignment
73
+ def permitted_attributes_for_create
74
+ super - [:#{user_table}]
75
+ end
76
+ RUBY
77
+ inject_into_file policy_path, content, after: /include #{dest_name.camelize}::ResourcePolicy\n/
78
+ end
79
+
80
+ def customize_definition
81
+ # Add ShowPage with SecuritySection
82
+ content = indent(<<~RUBY, 2)
83
+
84
+ class ShowPage < ShowPage
85
+ private
86
+
87
+ def render_after_content
88
+ render Plutonium::Profile::SecuritySection.new
89
+ end
90
+ end
91
+ RUBY
92
+ inject_into_file definition_path, content, after: /include #{dest_name.camelize}::ResourceDefinition\n/
93
+ end
94
+
95
+ def customize_controller
96
+ # Set user automatically when creating profile
97
+ content = <<-RUBY.chomp
98
+
99
+ private
100
+
101
+ def resource_params
102
+ super.merge(#{user_table}: current_user)
103
+ end
104
+ RUBY
105
+ inject_into_file controller_path, content, after: /include #{dest_name.camelize}::Concerns::Controller\n/
106
+ end
107
+
108
+ def add_profile_url_helper
109
+ content = <<-RUBY.chomp
110
+
111
+ included do
112
+ helper_method :profile_url
113
+ end
114
+
115
+ private
116
+
117
+ # Returns the URL to the user's profile page.
118
+ def profile_url
119
+ profile = current_user.#{profile_association}
120
+ if profile
121
+ resource_url_for(profile)
122
+ else
123
+ resource_url_for(#{resource_class_name}, action: :new)
124
+ end
125
+ end
126
+ RUBY
127
+ inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
128
+ end
129
+
130
+ def profile_association
131
+ resource_class_name.demodulize.underscore
132
+ end
133
+
134
+ def resource_class_name
135
+ name.camelize
136
+ end
137
+
138
+ def user_table
139
+ options[:user_model].underscore
140
+ end
141
+
142
+ def dest_name
143
+ selected_destination_portal
144
+ end
145
+
146
+ def concerns_controller_path
147
+ "packages/#{dest_name}/app/controllers/#{dest_name}/concerns/controller.rb"
148
+ end
149
+
150
+ def controller_path
151
+ "packages/#{dest_name}/app/controllers/#{dest_name}/#{resource_class_name.underscore.pluralize}_controller.rb"
152
+ end
153
+
154
+ def policy_path
155
+ "packages/#{dest_name}/app/policies/#{dest_name}/#{resource_class_name.underscore}_policy.rb"
156
+ end
157
+
158
+ def definition_path
159
+ "packages/#{dest_name}/app/definitions/#{dest_name}/#{resource_class_name.underscore}_definition.rb"
160
+ end
161
+
162
+ def selected_destination_portal
163
+ @selected_destination_portal ||= portal_option :dest, prompt: "Select destination portal"
164
+ end
165
+ end
166
+ end
167
+ end