plutonium 0.43.2 → 0.44.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +19 -0
  3. data/Rakefile +24 -6
  4. data/app/assets/plutonium.css +1 -1
  5. data/config/initializers/pagy.rb +1 -4
  6. data/gemfiles/rails_7.gemfile.lock +8 -3
  7. data/gemfiles/rails_8.0.gemfile.lock +7 -3
  8. data/gemfiles/rails_8.1.gemfile.lock +7 -3
  9. data/lib/generators/pu/invites/install_generator.rb +69 -11
  10. data/lib/generators/pu/invites/templates/INSTRUCTIONS +4 -1
  11. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +1 -1
  12. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +1 -1
  13. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +5 -1
  14. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +14 -24
  15. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +2 -0
  16. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +3 -2
  17. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +3 -2
  18. data/lib/generators/pu/lib/plutonium_generators/concerns/logger.rb +2 -0
  19. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -6
  20. data/lib/generators/pu/lib/plutonium_generators/concerns/rodauth_redirects.rb +41 -0
  21. data/lib/generators/pu/lib/plutonium_generators/generator.rb +5 -1
  22. data/lib/generators/pu/lib/plutonium_generators/non_interactive_prompt.rb +27 -0
  23. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +4 -0
  24. data/lib/generators/pu/saas/entity_generator.rb +16 -0
  25. data/lib/generators/pu/saas/membership_generator.rb +4 -3
  26. data/lib/generators/pu/saas/portal/USAGE +15 -0
  27. data/lib/generators/pu/saas/portal_generator.rb +122 -0
  28. data/lib/generators/pu/saas/setup/USAGE +17 -22
  29. data/lib/generators/pu/saas/setup_generator.rb +62 -9
  30. data/lib/generators/pu/saas/welcome/USAGE +27 -0
  31. data/lib/generators/pu/saas/welcome/templates/app/controllers/authenticated_controller.rb.tt +18 -0
  32. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +69 -0
  33. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +33 -0
  34. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +51 -0
  35. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/select_entity.html.erb.tt +22 -0
  36. data/lib/generators/pu/saas/welcome_generator.rb +197 -0
  37. data/lib/plutonium/auth/sequel_adapter.rb +1 -2
  38. data/lib/plutonium/core/controller.rb +18 -8
  39. data/lib/plutonium/core/controllers/association_resolver.rb +19 -6
  40. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -1
  41. data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
  42. data/lib/plutonium/resource/controller.rb +2 -3
  43. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
  44. data/lib/plutonium/resource/controllers/presentable.rb +2 -0
  45. data/lib/plutonium/ui/table/components/pagy_info.rb +1 -7
  46. data/lib/plutonium/ui/table/components/pagy_pagination.rb +4 -6
  47. data/lib/plutonium/version.rb +1 -1
  48. data/package.json +1 -1
  49. data/plutonium.gemspec +1 -1
  50. metadata +16 -8
@@ -1,10 +1,10 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.43.1)
4
+ plutonium (0.43.2)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
- pagy (~> 9.0)
7
+ pagy (~> 43.0)
8
8
  phlex (~> 2.0)
9
9
  phlex-rails
10
10
  phlex-slotable (>= 1.0.0)
@@ -187,7 +187,10 @@ GEM
187
187
  nio4r (2.7.5)
188
188
  nokogiri (1.19.1-arm64-darwin)
189
189
  racc (~> 1.4)
190
- pagy (9.4.0)
190
+ pagy (43.3.3)
191
+ json
192
+ uri
193
+ yaml
191
194
  parallel (1.27.0)
192
195
  parser (3.3.10.2)
193
196
  ast (~> 2.4.1)
@@ -391,6 +394,7 @@ GEM
391
394
  websocket-extensions (>= 0.1.0)
392
395
  websocket-extensions (0.1.5)
393
396
  wisper (2.0.1)
397
+ yaml (0.4.0)
394
398
  zeitwerk (2.7.5)
395
399
 
396
400
  PLATFORMS
@@ -9,6 +9,7 @@ module Pu
9
9
  class InstallGenerator < ::Rails::Generators::Base
10
10
  include ::ActiveRecord::Generators::Migration
11
11
  include PlutoniumGenerators::Generator
12
+ include PlutoniumGenerators::Concerns::RodauthRedirects
12
13
 
13
14
  source_root File.expand_path("templates", __dir__)
14
15
 
@@ -21,7 +22,7 @@ module Pu
21
22
  desc: "The user model name"
22
23
 
23
24
  class_option :membership_model, type: :string,
24
- desc: "The membership model name (defaults to <Entity>User)"
25
+ desc: "The membership model name (defaults to <Entity><User>)"
25
26
 
26
27
  class_option :rodauth, type: :string, default: "user",
27
28
  desc: "Rodauth configuration name for signup integration"
@@ -153,7 +154,7 @@ module Pu
153
154
 
154
155
  def add_entity_policy
155
156
  inject_into_file entity_policy_path,
156
- "def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
157
+ "def invite_user?\n current_membership&.owner?\n end\n\n ",
157
158
  before: "# Core attributes"
158
159
  end
159
160
 
@@ -170,7 +171,7 @@ module Pu
170
171
 
171
172
  def add_user_policy
172
173
  inject_into_file user_policy_path,
173
- "def invite_user?\n false # TODO: e.g., current_membership&.admin? or user.is_a?(Admin)\n end\n\n ",
174
+ "def invite_user?\n current_membership&.owner?\n end\n\n ",
174
175
  before: "# Core attributes"
175
176
  end
176
177
 
@@ -201,11 +202,18 @@ module Pu
201
202
  end
202
203
 
203
204
  def add_routes
205
+ routes_content = File.read(Rails.root.join("config/routes.rb"))
206
+ if routes_content.include?("# User invitation routes")
207
+ say_status :skip, "Invitation routes already present", :yellow
208
+ return
209
+ end
210
+
204
211
  route_code = <<-RUBY
205
212
 
206
- # User invitation routes (public, unauthenticated)
213
+ # User invitation routes
207
214
  scope module: :invites do
208
- get "welcome", to: "welcome#index", as: :invites_welcome
215
+ get "invitations/welcome", to: "welcome#index", as: :invites_welcome_check
216
+ delete "invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
209
217
  get "invitations/:token", to: "user_invitations#show", as: :invitation
210
218
  post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
211
219
  get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
@@ -216,6 +224,20 @@ module Pu
216
224
  inject_into_file "config/routes.rb",
217
225
  route_code,
218
226
  before: /^end\s*\z/
227
+
228
+ # If no main WelcomeController exists, add /welcome route pointing to
229
+ # Invites::WelcomeController so Rodauth's login_redirect "/welcome" works.
230
+ unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb"))
231
+ welcome_route = <<-RUBY
232
+
233
+ # Welcome route (handled by invites package — replace with pu:saas:welcome for full onboarding)
234
+ get "welcome", to: "invites/welcome#index"
235
+ RUBY
236
+
237
+ inject_into_file "config/routes.rb",
238
+ welcome_route,
239
+ before: /^\s*# User invitation routes/
240
+ end
219
241
  end
220
242
 
221
243
  def configure_rodauth
@@ -297,10 +319,46 @@ module Pu
297
319
  after: /login_redirect.*\n/
298
320
  end
299
321
 
300
- # Update login_redirect to /welcome
301
- gsub_file rodauth_file,
302
- /login_redirect\s+["']\/["']/,
303
- 'login_redirect "/welcome"'
322
+ # Update login_redirect and create_account_redirect to /welcome
323
+ update_rodauth_redirects(rodauth_file)
324
+ end
325
+
326
+ def integrate_with_welcome_controller
327
+ welcome_controller_path = Rails.root.join("app/controllers/welcome_controller.rb")
328
+ return unless File.exist?(welcome_controller_path)
329
+
330
+ file_content = File.read(welcome_controller_path)
331
+ return if file_content.include?("PendingInviteCheck")
332
+
333
+ relative_path = "app/controllers/welcome_controller.rb"
334
+
335
+ # Add PendingInviteCheck concern
336
+ inject_into_file relative_path,
337
+ " include Plutonium::Invites::PendingInviteCheck\n",
338
+ after: /class WelcomeController.*\n/
339
+
340
+ # Add invite check as first step in index
341
+ inject_into_file relative_path,
342
+ " return redirect_to(invites_welcome_check_path) if pending_invite\n\n",
343
+ after: /def index\n/
344
+
345
+ # Add invite_class method if not present
346
+ unless file_content.include?("def invite_class")
347
+ inject_into_file relative_path,
348
+ "\n def invite_class\n ::Invites::UserInvite\n end\n",
349
+ before: /^end\s*\z/
350
+ end
351
+
352
+ # Update Invites::WelcomeController to redirect to /welcome (the main hub)
353
+ # instead of / (the app root)
354
+ invites_welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
355
+ if File.exist?(Rails.root.join(invites_welcome_path))
356
+ gsub_file invites_welcome_path,
357
+ /def default_redirect_path\n\s*"\/"\n\s*end/,
358
+ "def default_redirect_path\n \"/welcome\"\n end"
359
+ end
360
+
361
+ say_status :info, "Integrated invite check into WelcomeController", :green
304
362
  end
305
363
 
306
364
  def show_instructions
@@ -373,13 +431,13 @@ module Pu
373
431
  end
374
432
 
375
433
  def membership_model
376
- options[:membership_model] || "#{entity_model}User"
434
+ options[:membership_model] || "#{entity_model}#{user_model}"
377
435
  end
378
436
 
379
437
  def membership_model_file
380
438
  model_path = "#{membership_model.underscore}.rb"
381
439
  if entity_in_package?
382
- Rails.root.join("packages", options[:dest], "app/models", model_path)
440
+ Rails.root.join("packages", entity_package, "app/models", model_path)
383
441
  else
384
442
  Rails.root.join("app/models", model_path)
385
443
  end
@@ -12,7 +12,10 @@ Next steps:
12
12
  # packages/<portal>/config/routes.rb
13
13
  register_resource ::Invites::UserInvite
14
14
 
15
- 3. (Optional) Connect invitable models that trigger invites:
15
+ 3. (Optional) Set up the welcome/onboarding flow if you haven't already:
16
+ rails g pu:saas:welcome --user-model=User --entity-model=Organization --portal=CustomerPortal
17
+
18
+ 4. (Optional) Connect invitable models that trigger invites:
16
19
  rails g pu:invites:invitable Tenant
17
20
  rails g pu:invites:invitable TeamMember --role=member
18
21
 
@@ -6,7 +6,7 @@ class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interact
6
6
  presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
7
 
8
8
  attribute :role
9
- input :role, as: :select, choices: Invites::UserInvite.roles.keys
9
+ input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
10
10
  <% if membership_model != "EntityUser" || user_model != "User" -%>
11
11
 
12
12
  private
@@ -6,7 +6,7 @@ class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interactio
6
6
  presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
7
 
8
8
  attribute :role
9
- input :role, as: :select, choices: Invites::UserInvite.roles.keys
9
+ input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
10
10
 
11
11
  private
12
12
 
@@ -14,7 +14,7 @@ module Invites
14
14
  private
15
15
 
16
16
  def invite_class
17
- Invites::UserInvite
17
+ ::Invites::UserInvite
18
18
  end
19
19
 
20
20
  def user_class
@@ -22,7 +22,11 @@ module Invites
22
22
  end
23
23
 
24
24
  def after_accept_path
25
+ <% if rodauth? -%>
26
+ rodauth.login_redirect
27
+ <% else -%>
25
28
  "/"
29
+ <% end -%>
26
30
  end
27
31
 
28
32
  def login_path
@@ -4,16 +4,17 @@ module Invites
4
4
  class WelcomeController < ApplicationController
5
5
  <% if rodauth? -%>
6
6
  include Plutonium::Auth::Rodauth(:<%= rodauth_config %>)
7
+
8
+ before_action { rodauth.require_authentication }
9
+ <% else -%>
10
+ before_action :require_authentication
11
+
7
12
  <% end -%>
8
13
  include Plutonium::Invites::PendingInviteCheck
9
14
 
10
15
  prepend_view_path Invites::Engine.root.join("app/views")
11
16
  layout "invites/invitation"
12
17
 
13
- <% if rodauth? -%>
14
- before_action :require_authentication
15
- <% end -%>
16
-
17
18
  def index
18
19
  @invite = pending_invite
19
20
 
@@ -24,10 +25,15 @@ module Invites
24
25
  end
25
26
  end
26
27
 
28
+ def skip
29
+ cookies.delete(:pending_invitation)
30
+ redirect_to after_welcome_path, allow_other_host: false
31
+ end
32
+
27
33
  private
28
34
 
29
35
  def invite_class
30
- Invites::UserInvite
36
+ ::Invites::UserInvite
31
37
  end
32
38
 
33
39
  # Returns the path to redirect to after the welcome flow completes.
@@ -40,30 +46,14 @@ module Invites
40
46
  def default_redirect_path
41
47
  "/"
42
48
  end
49
+ <% unless rodauth? -%>
43
50
 
44
- <% if rodauth? -%>
45
- def require_authentication
46
- redirect_to login_path unless current_user
47
- end
48
-
49
- # Override: the default current_user raises when not logged in,
50
- # but this controller needs a nil return for the authentication check.
51
- def current_user
52
- rodauth.rails_account if rodauth.logged_in?
53
- end
54
-
55
- def login_path
56
- rodauth.login_path
57
- end
58
- <% else -%>
59
51
  def require_authentication
60
- # TODO: Implement based on your authentication system
61
- redirect_to "/login" unless current_user
52
+ raise NotImplementedError, "#{self.class}#require_authentication must be implemented for your authentication system"
62
53
  end
63
54
 
64
55
  def current_user
65
- # TODO: Implement based on your authentication system
66
- nil
56
+ raise NotImplementedError, "#{self.class}#current_user must be implemented for your authentication system"
67
57
  end
68
58
  <% end -%>
69
59
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Invites
4
4
  class UserInviteMailer < ApplicationMailer
5
+ prepend_view_path Invites::Engine.root.join("app/views")
6
+
5
7
  def invitation(user_invite)
6
8
  @user_invite = user_invite
7
9
  @invitation_url = invitation_url(token: user_invite.token)
@@ -32,8 +32,9 @@
32
32
  <div class="space-y-3 pt-4">
33
33
  <%%= form.submit "Accept Invitation",
34
34
  class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
35
- <%%= link_to "Cancel", "/",
36
- class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
35
+ <%%= button_to "Skip for Now", invites_welcome_skip_path,
36
+ method: :delete,
37
+ class: "w-full pu-btn pu-btn-md pu-btn-outline text-center" %>
37
38
  </div>
38
39
  <%% end %>
39
40
  </div>
@@ -18,6 +18,7 @@
18
18
  <%%= link_to "View Invitation", invitation_path(token: @invite.token),
19
19
  class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
20
20
 
21
- <%%= link_to "Skip for Now", "/",
22
- class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
21
+ <%%= button_to "Skip for Now", invites_welcome_skip_path,
22
+ method: :delete,
23
+ class: "w-full pu-btn pu-btn-md pu-btn-outline text-center" %>
23
24
  </div>
@@ -25,6 +25,8 @@ module PlutoniumGenerators
25
25
  end
26
26
 
27
27
  def exception(msg, err)
28
+ raise err if Rails.env.test?
29
+
28
30
  error "#{msg}\n\n#{err.class}: #{err}\n#{err.backtrace.join("\n")}"
29
31
  end
30
32
 
@@ -21,18 +21,16 @@ module PlutoniumGenerators
21
21
  end
22
22
 
23
23
  def available_packages
24
- @available_packages ||= begin
25
- packages = Dir[Rails.root.join("packages", "*")].map { |dir| File.basename(dir) }
26
- packages - reserved_packages
27
- end
24
+ packages = Dir[Rails.root.join("packages", "*")].map { |dir| File.basename(dir) }
25
+ packages - reserved_packages
28
26
  end
29
27
 
30
28
  def available_portals
31
- @available_portals ||= ["main_app"] + available_packages.select { |pkg| pkg.ends_with?("_app") || pkg.ends_with?("_portal") }.sort
29
+ ["main_app"] + available_packages.select { |pkg| pkg.ends_with?("_app") || pkg.ends_with?("_portal") }.sort
32
30
  end
33
31
 
34
32
  def available_features
35
- @available_features ||= ["main_app"] + available_packages.select { |pkg| !(pkg.ends_with?("_app") || pkg.ends_with?("_portal")) }.sort
33
+ ["main_app"] + available_packages.select { |pkg| !(pkg.ends_with?("_app") || pkg.ends_with?("_portal")) }.sort
36
34
  end
37
35
 
38
36
  def select_package(selected_package = nil, msg: "Select package", pkgs: nil)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlutoniumGenerators
4
+ module Concerns
5
+ # Shared logic for updating Rodauth redirect configuration.
6
+ # Used by generators that need to point login/create_account redirects to /welcome.
7
+ module RodauthRedirects
8
+ # Updates login_redirect and create_account_redirect in a Rodauth plugin file
9
+ # to point to the given path (typically "/welcome").
10
+ #
11
+ # @param rodauth_file [String] relative path to the rodauth plugin file
12
+ # @param redirect_path [String] the path to redirect to (default: "/welcome")
13
+ def update_rodauth_redirects(rodauth_file, redirect_path: "/welcome")
14
+ unless File.exist?(Rails.root.join(rodauth_file))
15
+ say_status :skip, "Rodauth plugin not found: #{rodauth_file}", :yellow
16
+ return
17
+ end
18
+
19
+ file_content = File.read(Rails.root.join(rodauth_file))
20
+
21
+ # Update login_redirect
22
+ if file_content.match?(/login_redirect\s+["']\/["']/)
23
+ gsub_file rodauth_file,
24
+ /login_redirect\s+["']\/["']/,
25
+ "login_redirect \"#{redirect_path}\""
26
+ end
27
+
28
+ # Update or add create_account_redirect
29
+ if file_content.include?("create_account_redirect")
30
+ gsub_file rodauth_file,
31
+ /create_account_redirect\s+["']\/["']/,
32
+ "create_account_redirect \"#{redirect_path}\""
33
+ elsif file_content.include?("login_redirect")
34
+ inject_into_file rodauth_file,
35
+ "\n create_account_redirect \"#{redirect_path}\"\n",
36
+ after: /login_redirect.*\n/
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -52,7 +52,11 @@ module PlutoniumGenerators
52
52
  # ####################
53
53
 
54
54
  def prompt
55
- @prompt ||= TTY::Prompt.new
55
+ @prompt ||= if options[:interactive] == false || Rails.env.test?
56
+ NonInteractivePrompt.new
57
+ else
58
+ TTY::Prompt.new
59
+ end
56
60
  end
57
61
 
58
62
  # def appname
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PlutoniumGenerators
4
+ # A drop-in replacement for TTY::Prompt that raises on any interactive method.
5
+ # Used when --no-interactive is passed or when there's no TTY (e.g., tests, CI).
6
+ class NonInteractivePrompt
7
+ def select(question, choices = nil, **)
8
+ raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
9
+ end
10
+
11
+ def ask(question, **)
12
+ raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
13
+ end
14
+
15
+ def yes?(question, **)
16
+ raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
17
+ end
18
+
19
+ def no?(question, **)
20
+ raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
21
+ end
22
+
23
+ def multi_select(question, **)
24
+ raise Thor::Error, "Interactive prompt not available: #{question}. Provide the required option explicitly."
25
+ end
26
+ end
27
+ end
@@ -31,5 +31,9 @@ class <%= account_path.classify %> < ResourceRecord
31
31
 
32
32
  # add misc attribute macros above.
33
33
 
34
+ def to_label
35
+ <%= login_column %>
36
+ end
37
+
34
38
  # add methods above. add private methods below.
35
39
  end
@@ -16,6 +16,7 @@ module Pu
16
16
  def start
17
17
  generate_entity_resource
18
18
  add_unique_index_to_migration
19
+ add_dynamic_path_parameter
19
20
  rescue => e
20
21
  exception "#{self.class} failed:", e
21
22
  end
@@ -43,6 +44,21 @@ module Pu
43
44
  before: /^ end\s*$/
44
45
  end
45
46
 
47
+ def add_dynamic_path_parameter
48
+ dest = selected_destination_feature
49
+ model_path = if dest == "main_app"
50
+ "app/models/#{normalized_name}.rb"
51
+ else
52
+ "packages/#{dest}/app/models/#{normalized_name}.rb"
53
+ end
54
+
55
+ return unless File.exist?(Rails.root.join(model_path))
56
+
57
+ inject_into_file model_path,
58
+ " dynamic_path_parameter :name\n",
59
+ before: /^\s*# add model configurations above\./
60
+ end
61
+
46
62
  def entity_attributes
47
63
  ["name:string", *Array(options[:extra_attributes])]
48
64
  end
@@ -16,8 +16,8 @@ module Pu
16
16
  class_option :entity, type: :string, required: true,
17
17
  desc: "The entity model name (e.g., Organization)"
18
18
 
19
- class_option :roles, type: :array, default: %w[member owner],
20
- desc: "Available roles for memberships"
19
+ class_option :roles, type: :array, default: %w[admin member],
20
+ desc: "Additional roles for memberships (owner is always included as the first role)"
21
21
 
22
22
  class_option :extra_attributes, type: :array, default: [],
23
23
  desc: "Additional attributes for the membership model"
@@ -211,7 +211,8 @@ module Pu
211
211
  end
212
212
 
213
213
  def roles
214
- Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
214
+ additional = Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
215
+ ["owner", *additional.excluding("owner")]
215
216
  end
216
217
 
217
218
  def roles_enum
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Generates a SaaS portal with entity scoping, entity self-management,
3
+ and navigation helpers.
4
+
5
+ Wraps pu:pkg:portal and adds:
6
+ - Entity registered as a singular resource
7
+ - Portal-scoped EntityPolicy (update=owner, no destroy)
8
+ - entity_url and user_entities helpers in portal concerns
9
+ - Entity controller for the portal
10
+
11
+ Example:
12
+ rails g pu:saas:portal --entity-model=Organization --portal-name=Dashboard
13
+
14
+ This generates a DashboardPortal scoped to Organization, with
15
+ entity management and navigation helpers built in.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require_relative "../lib/plutonium_generators"
5
+
6
+ module Pu
7
+ module Saas
8
+ class PortalGenerator < ::Rails::Generators::Base
9
+ include PlutoniumGenerators::Generator
10
+
11
+ desc "Generate a SaaS portal with entity scoping, entity management, and navigation helpers"
12
+
13
+ class_option :entity_model, type: :string, required: true,
14
+ desc: "The entity model name (e.g., Organization)"
15
+
16
+ class_option :user_model, type: :string, default: "User",
17
+ desc: "The user model name"
18
+
19
+ class_option :portal_name, type: :string, default: "Dashboard",
20
+ desc: "Portal name (e.g., Dashboard generates DashboardPortal)"
21
+
22
+ class_option :rodauth, type: :string, default: "user",
23
+ desc: "Rodauth configuration name"
24
+
25
+ def start
26
+ create_portal
27
+ connect_entity_to_portal
28
+ customize_entity_policy
29
+ add_entity_url_helper
30
+ rescue => e
31
+ exception "#{self.class} failed:", e
32
+ end
33
+
34
+ private
35
+
36
+ def create_portal
37
+ generate "pu:pkg:portal", "#{options[:portal_name]} --auth=#{rodauth_config} --scope=#{entity_model}"
38
+ end
39
+
40
+ def connect_entity_to_portal
41
+ # Shell out so the subprocess can load the newly created entity model
42
+ generate "pu:res:conn", "#{entity_model} --dest=#{portal_package} --singular --policy"
43
+ end
44
+
45
+ def customize_entity_policy
46
+ content = <<-RUBY
47
+
48
+ def update?
49
+ current_membership&.owner?
50
+ end
51
+
52
+ def destroy?
53
+ false
54
+ end
55
+
56
+ def permitted_attributes_for_read
57
+ [:name]
58
+ end
59
+
60
+ def permitted_attributes_for_update
61
+ [:name]
62
+ end
63
+
64
+ def permitted_associations
65
+ []
66
+ end
67
+ RUBY
68
+ inject_into_file entity_policy_path, content, after: /include #{portal_engine}::ResourcePolicy\n/
69
+ end
70
+
71
+ def add_entity_url_helper
72
+ content = <<-RUBY
73
+
74
+ included do
75
+ helper_method :entity_url, :user_entities
76
+ end
77
+
78
+ private
79
+
80
+ # Returns the URL to the current entity's show page.
81
+ def entity_url
82
+ resource_url_for(current_scoped_entity)
83
+ end
84
+
85
+ # Returns all entities the current user belongs to (for the entity switcher).
86
+ def user_entities
87
+ @user_entities ||= current_user.#{entity_table.pluralize}
88
+ end
89
+ RUBY
90
+ inject_into_file concerns_controller_path, content, after: /# add concerns above\.\n/
91
+ end
92
+
93
+ def entity_model
94
+ options[:entity_model].camelize
95
+ end
96
+
97
+ def entity_table
98
+ options[:entity_model].underscore
99
+ end
100
+
101
+ def rodauth_config
102
+ options[:rodauth]
103
+ end
104
+
105
+ def portal_engine
106
+ "#{options[:portal_name].camelize}Portal"
107
+ end
108
+
109
+ def portal_package
110
+ portal_engine.underscore
111
+ end
112
+
113
+ def concerns_controller_path
114
+ "packages/#{portal_package}/app/controllers/#{portal_package}/concerns/controller.rb"
115
+ end
116
+
117
+ def entity_policy_path
118
+ "packages/#{portal_package}/app/policies/#{portal_package}/#{entity_table}_policy.rb"
119
+ end
120
+ end
121
+ end
122
+ end