plutonium 0.43.1 → 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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +35 -0
  4. data/Rakefile +24 -6
  5. data/app/assets/plutonium.css +1 -1
  6. data/config/initializers/pagy.rb +1 -4
  7. data/gemfiles/rails_7.gemfile +1 -1
  8. data/gemfiles/rails_7.gemfile.lock +128 -103
  9. data/gemfiles/rails_8.0.gemfile +1 -1
  10. data/gemfiles/rails_8.0.gemfile.lock +56 -46
  11. data/gemfiles/rails_8.1.gemfile +1 -1
  12. data/gemfiles/rails_8.1.gemfile.lock +108 -98
  13. data/lib/generators/pu/invites/install_generator.rb +69 -11
  14. data/lib/generators/pu/invites/templates/INSTRUCTIONS +4 -1
  15. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +1 -1
  16. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +1 -1
  17. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +13 -8
  18. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +17 -26
  19. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +1 -1
  20. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +2 -0
  21. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +3 -2
  22. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +3 -2
  23. data/lib/generators/pu/lib/plutonium_generators/concerns/logger.rb +2 -0
  24. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -6
  25. data/lib/generators/pu/lib/plutonium_generators/concerns/rodauth_redirects.rb +41 -0
  26. data/lib/generators/pu/lib/plutonium_generators/generator.rb +5 -1
  27. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +0 -1
  28. data/lib/generators/pu/lib/plutonium_generators/non_interactive_prompt.rb +27 -0
  29. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +4 -0
  30. data/lib/generators/pu/saas/entity_generator.rb +16 -0
  31. data/lib/generators/pu/saas/membership_generator.rb +4 -3
  32. data/lib/generators/pu/saas/portal/USAGE +15 -0
  33. data/lib/generators/pu/saas/portal_generator.rb +122 -0
  34. data/lib/generators/pu/saas/setup/USAGE +17 -22
  35. data/lib/generators/pu/saas/setup_generator.rb +62 -9
  36. data/lib/generators/pu/saas/welcome/USAGE +27 -0
  37. data/lib/generators/pu/saas/welcome/templates/app/controllers/authenticated_controller.rb.tt +18 -0
  38. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +69 -0
  39. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +33 -0
  40. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +51 -0
  41. data/lib/generators/pu/saas/welcome/templates/app/views/welcome/select_entity.html.erb.tt +22 -0
  42. data/lib/generators/pu/saas/welcome_generator.rb +197 -0
  43. data/lib/plutonium/auth/sequel_adapter.rb +1 -2
  44. data/lib/plutonium/core/controller.rb +18 -8
  45. data/lib/plutonium/core/controllers/association_resolver.rb +19 -6
  46. data/lib/plutonium/core/controllers/authorizable.rb +11 -0
  47. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -1
  48. data/lib/plutonium/invites/concerns/invite_token.rb +0 -8
  49. data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
  50. data/lib/plutonium/resource/controller.rb +2 -3
  51. data/lib/plutonium/resource/controllers/authorizable.rb +4 -5
  52. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
  53. data/lib/plutonium/resource/controllers/presentable.rb +2 -0
  54. data/lib/plutonium/ui/table/components/pagy_info.rb +1 -7
  55. data/lib/plutonium/ui/table/components/pagy_pagination.rb +4 -6
  56. data/lib/plutonium/version.rb +1 -1
  57. data/package.json +1 -1
  58. data/plutonium.gemspec +2 -2
  59. metadata +18 -10
@@ -2,15 +2,19 @@
2
2
 
3
3
  module Invites
4
4
  class WelcomeController < ApplicationController
5
+ <% if rodauth? -%>
6
+ include Plutonium::Auth::Rodauth(:<%= rodauth_config %>)
7
+
8
+ before_action { rodauth.require_authentication }
9
+ <% else -%>
10
+ before_action :require_authentication
11
+
12
+ <% end -%>
5
13
  include Plutonium::Invites::PendingInviteCheck
6
14
 
7
15
  prepend_view_path Invites::Engine.root.join("app/views")
8
16
  layout "invites/invitation"
9
17
 
10
- <% if rodauth? -%>
11
- before_action :require_authentication
12
- <% end -%>
13
-
14
18
  def index
15
19
  @invite = pending_invite
16
20
 
@@ -21,10 +25,15 @@ module Invites
21
25
  end
22
26
  end
23
27
 
28
+ def skip
29
+ cookies.delete(:pending_invitation)
30
+ redirect_to after_welcome_path, allow_other_host: false
31
+ end
32
+
24
33
  private
25
34
 
26
35
  def invite_class
27
- Invites::UserInvite
36
+ ::Invites::UserInvite
28
37
  end
29
38
 
30
39
  # Returns the path to redirect to after the welcome flow completes.
@@ -37,32 +46,14 @@ module Invites
37
46
  def default_redirect_path
38
47
  "/"
39
48
  end
49
+ <% unless rodauth? -%>
40
50
 
41
- <% if rodauth? -%>
42
- def require_authentication
43
- redirect_to login_path unless current_user
44
- end
45
-
46
- def current_user
47
- rodauth.rails_account if rodauth.logged_in?
48
- end
49
-
50
- def login_path
51
- rodauth.login_path
52
- end
53
-
54
- def rodauth
55
- request.env["rodauth.<%= rodauth_config %>"]
56
- end
57
- <% else -%>
58
51
  def require_authentication
59
- # TODO: Implement based on your authentication system
60
- redirect_to "/login" unless current_user
52
+ raise NotImplementedError, "#{self.class}#require_authentication must be implemented for your authentication system"
61
53
  end
62
54
 
63
55
  def current_user
64
- # TODO: Implement based on your authentication system
65
- nil
56
+ raise NotImplementedError, "#{self.class}#current_user must be implemented for your authentication system"
66
57
  end
67
58
  <% end -%>
68
59
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class CancelInviteInteraction < Plutonium::Resource::Interaction
4
+ class CancelInviteInteraction < Invites::ResourceInteraction
5
5
  include Plutonium::Invites::Concerns::CancelInvite
6
6
  end
7
7
  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
@@ -254,7 +254,6 @@ module PlutoniumGenerators
254
254
  value
255
255
  end
256
256
  end
257
-
258
257
  end
259
258
 
260
259
  def required?
@@ -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
@@ -1,27 +1,22 @@
1
1
  Description:
2
- Generate a complete SaaS setup with user account, entity, and membership.
3
- This is the recommended way to set up multi-tenant authentication.
2
+ Generate a complete SaaS setup with user account, entity, membership,
3
+ portal, welcome flow, invites, and profile.
4
+
5
+ This is the recommended way to bootstrap a multi-tenant SaaS app.
4
6
 
5
7
  Example:
6
- rails g pu:saas:setup --user Customer --entity Organization
7
- rails g pu:saas:setup --user Customer --entity Organization --roles=member,admin,owner
8
- rails g pu:saas:setup --user Customer --entity Organization --no-allow-signup
9
- rails g pu:saas:setup --user Customer --entity Organization --user-attributes=name:string
10
- rails g pu:saas:setup --user Customer --entity Organization --skip-entity
8
+ rails g pu:saas:setup --user=User --entity=Organization
11
9
 
12
- This creates:
13
- 1. User account (Customer) with Rodauth authentication
14
- 2. Entity model (Organization) with unique name
15
- 3. Membership model (OrganizationCustomer) with role enum
10
+ Full setup with all defaults:
11
+ - User model with Rodauth authentication
12
+ - Entity model with unique name and dynamic path parameter
13
+ - Membership model with owner/admin/member roles
14
+ - DashboardPortal with entity scoping
15
+ - Welcome/onboarding flow
16
+ - User invites package
17
+ - User profile resource
16
18
 
17
- Options:
18
- --user User model name (required)
19
- --entity Entity model name (required)
20
- --allow-signup Allow public registration (default: true)
21
- --roles Comma-separated membership roles (default: member,owner)
22
- --skip-entity Skip entity model generation
23
- --skip-membership Skip membership model generation
24
- --user-attributes Additional user model attributes
25
- --entity-attributes Additional entity model attributes
26
- --membership-attributes Additional membership model attributes
27
- --dest Destination package (default: main_app)
19
+ Customize:
20
+ rails g pu:saas:setup --user=User --entity=Organization --portal-name=Customer
21
+ rails g pu:saas:setup --user=User --entity=Organization --no-invites --no-profile
22
+ rails g pu:saas:setup --user=User --entity=Organization --no-portal
@@ -8,10 +8,10 @@ module Pu
8
8
  class SetupGenerator < ::Rails::Generators::Base
9
9
  include PlutoniumGenerators::Generator
10
10
 
11
- desc "Generate a complete SaaS setup with user, entity, and membership"
11
+ desc "Generate a complete SaaS setup with user, entity, membership, portal, and welcome flow"
12
12
 
13
13
  class_option :user, type: :string, required: true,
14
- desc: "The user model name (e.g., Customer)"
14
+ desc: "The user model name (e.g., User)"
15
15
 
16
16
  class_option :entity, type: :string, required: true,
17
17
  desc: "The entity model name (e.g., Organization)"
@@ -19,8 +19,8 @@ module Pu
19
19
  class_option :allow_signup, type: :boolean, default: true,
20
20
  desc: "Whether to allow users to sign up to the platform"
21
21
 
22
- class_option :roles, type: :array, default: %w[member owner],
23
- desc: "Available roles for memberships"
22
+ class_option :roles, type: :array, default: %w[admin member],
23
+ desc: "Additional roles for memberships (owner is always included as the first role)"
24
24
 
25
25
  class_option :skip_entity, type: :boolean, default: false,
26
26
  desc: "Skip entity model generation"
@@ -37,6 +37,21 @@ module Pu
37
37
  class_option :membership_attributes, type: :array, default: [],
38
38
  desc: "Additional attributes for the membership model"
39
39
 
40
+ class_option :portal, type: :boolean, default: true,
41
+ desc: "Generate a portal with entity scoping"
42
+
43
+ class_option :portal_name, type: :string, default: "Dashboard",
44
+ desc: "Portal name (e.g., Dashboard generates DashboardPortal)"
45
+
46
+ class_option :welcome, type: :boolean, default: true,
47
+ desc: "Generate the welcome/onboarding flow"
48
+
49
+ class_option :invites, type: :boolean, default: true,
50
+ desc: "Generate the user invites package"
51
+
52
+ class_option :profile, type: :boolean, default: true,
53
+ desc: "Generate user profile resource"
54
+
40
55
  class_option :api_client, type: :string, default: nil,
41
56
  desc: "Generate an API client model (e.g., ApiClient)"
42
57
 
@@ -48,6 +63,10 @@ module Pu
48
63
  generate_user
49
64
  generate_entity unless options[:skip_entity]
50
65
  generate_membership unless options[:skip_membership]
66
+ generate_portal if options[:portal] && !options[:skip_entity]
67
+ generate_profile if options[:profile]
68
+ generate_welcome if options[:welcome] && !options[:skip_entity] && options[:portal]
69
+ generate_invites if options[:invites] && !options[:skip_entity] && !options[:skip_membership]
51
70
  generate_api_client if options[:api_client].present?
52
71
  rescue => e
53
72
  exception "#{self.class} failed:", e
@@ -62,7 +81,6 @@ module Pu
62
81
  end
63
82
 
64
83
  def generate_user
65
- # Use class-based invocation to avoid Thor's invoke caching
66
84
  klass = Rails::Generators.find_by_namespace("pu:saas:user")
67
85
  klass.new(
68
86
  [options[:user]],
@@ -76,7 +94,6 @@ module Pu
76
94
  end
77
95
 
78
96
  def generate_entity
79
- # Use class-based invocation to avoid Thor's invoke caching
80
97
  klass = Rails::Generators.find_by_namespace("pu:saas:entity")
81
98
  klass.new(
82
99
  [options[:entity]],
@@ -90,7 +107,6 @@ module Pu
90
107
  end
91
108
 
92
109
  def generate_membership
93
- # Use class-based invocation to avoid Thor's invoke caching
94
110
  klass = Rails::Generators.find_by_namespace("pu:saas:membership")
95
111
  klass.new(
96
112
  [],
@@ -106,8 +122,34 @@ module Pu
106
122
  ).invoke_all
107
123
  end
108
124
 
125
+ def generate_portal
126
+ generate "pu:saas:portal",
127
+ "--entity-model=#{options[:entity]} --user-model=#{options[:user]}" \
128
+ " --portal-name=#{options[:portal_name]}" \
129
+ " --rodauth=#{rodauth_config}"
130
+ end
131
+
132
+ def generate_profile
133
+ generate "pu:profile:setup",
134
+ "--user-model=#{options[:user]} --dest=main_app" \
135
+ "#{" --portal=#{portal_package}" if options[:portal]}"
136
+ end
137
+
138
+ def generate_welcome
139
+ generate "pu:saas:welcome",
140
+ "--user-model=#{options[:user]} --entity-model=#{options[:entity]}" \
141
+ " --portal=#{portal_engine}" \
142
+ " --rodauth=#{rodauth_config}" \
143
+ "#{" --profile" if options[:profile]}"
144
+ end
145
+
146
+ def generate_invites
147
+ generate "pu:invites:install",
148
+ "--entity-model=#{options[:entity]} --user-model=#{options[:user]} --dest=main_app" \
149
+ " --rodauth=#{rodauth_config}"
150
+ end
151
+
109
152
  def generate_api_client
110
- # Use class-based invocation to avoid Thor's invoke caching
111
153
  klass = Rails::Generators.find_by_namespace("pu:saas:api_client")
112
154
  api_client_options = {
113
155
  roles: options[:api_client_roles],
@@ -116,11 +158,22 @@ module Pu
116
158
  skip: options[:skip]
117
159
  }
118
160
 
119
- # Scope to entity if entity is being generated
120
161
  api_client_options[:entity] = options[:entity] unless options[:skip_entity]
121
162
 
122
163
  klass.new([options[:api_client]], api_client_options).invoke_all
123
164
  end
165
+
166
+ def rodauth_config
167
+ options[:user].underscore
168
+ end
169
+
170
+ def portal_package
171
+ "#{options[:portal_name].underscore}_portal"
172
+ end
173
+
174
+ def portal_engine
175
+ "#{options[:portal_name].camelize}Portal"
176
+ end
124
177
  end
125
178
  end
126
179
  end
@@ -0,0 +1,27 @@
1
+ Description:
2
+ Generates a post-login welcome flow with onboarding and entity selection.
3
+
4
+ This creates the controllers, views, and routes needed to bridge
5
+ authentication and the main portal:
6
+
7
+ - AuthenticatedController: base controller requiring login
8
+ - WelcomeController: routing hub (invite check -> onboarding -> entity selection -> portal)
9
+ - Onboarding view: create first entity (and optional profile)
10
+ - Entity selection view: choose from multiple entities
11
+
12
+ Example:
13
+ rails g pu:saas:welcome --user-model=User --entity-model=Organization --portal=CustomerPortal
14
+
15
+ This generates:
16
+ app/controllers/authenticated_controller.rb
17
+ app/controllers/welcome_controller.rb
18
+ app/views/welcome/onboarding.html.erb
19
+ app/views/welcome/select_entity.html.erb
20
+
21
+ And adds routes:
22
+ GET /welcome -> welcome#index
23
+ GET /welcome/onboard -> welcome#new_entity
24
+ POST /welcome/onboard -> welcome#onboard
25
+
26
+ Options:
27
+ --profile Include profile setup in onboarding form