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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/CHANGELOG.md +35 -0
- data/Rakefile +24 -6
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/pagy.rb +1 -4
- data/gemfiles/rails_7.gemfile +1 -1
- data/gemfiles/rails_7.gemfile.lock +128 -103
- data/gemfiles/rails_8.0.gemfile +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +56 -46
- data/gemfiles/rails_8.1.gemfile +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +108 -98
- data/lib/generators/pu/invites/install_generator.rb +69 -11
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +4 -1
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +13 -8
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +17 -26
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +1 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +2 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +3 -2
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +3 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/logger.rb +2 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -6
- data/lib/generators/pu/lib/plutonium_generators/concerns/rodauth_redirects.rb +41 -0
- data/lib/generators/pu/lib/plutonium_generators/generator.rb +5 -1
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +0 -1
- data/lib/generators/pu/lib/plutonium_generators/non_interactive_prompt.rb +27 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +4 -0
- data/lib/generators/pu/saas/entity_generator.rb +16 -0
- data/lib/generators/pu/saas/membership_generator.rb +4 -3
- data/lib/generators/pu/saas/portal/USAGE +15 -0
- data/lib/generators/pu/saas/portal_generator.rb +122 -0
- data/lib/generators/pu/saas/setup/USAGE +17 -22
- data/lib/generators/pu/saas/setup_generator.rb +62 -9
- data/lib/generators/pu/saas/welcome/USAGE +27 -0
- data/lib/generators/pu/saas/welcome/templates/app/controllers/authenticated_controller.rb.tt +18 -0
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +69 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +33 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/onboarding.html.erb.tt +51 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/welcome/select_entity.html.erb.tt +22 -0
- data/lib/generators/pu/saas/welcome_generator.rb +197 -0
- data/lib/plutonium/auth/sequel_adapter.rb +1 -2
- data/lib/plutonium/core/controller.rb +18 -8
- data/lib/plutonium/core/controllers/association_resolver.rb +19 -6
- data/lib/plutonium/core/controllers/authorizable.rb +11 -0
- data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -1
- data/lib/plutonium/invites/concerns/invite_token.rb +0 -8
- data/lib/plutonium/invites/concerns/invite_user.rb +1 -1
- data/lib/plutonium/resource/controller.rb +2 -3
- data/lib/plutonium/resource/controllers/authorizable.rb +4 -5
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
- data/lib/plutonium/resource/controllers/presentable.rb +2 -0
- data/lib/plutonium/ui/table/components/pagy_info.rb +1 -7
- data/lib/plutonium/ui/table/components/pagy_pagination.rb +4 -6
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/plutonium.gemspec +2 -2
- 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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
@@ -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
|
-
<%%=
|
|
36
|
-
|
|
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
|
-
<%%=
|
|
22
|
-
|
|
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>
|
|
@@ -21,18 +21,16 @@ module PlutoniumGenerators
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def available_packages
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||=
|
|
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
|
|
@@ -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
|
|
20
|
-
desc: "
|
|
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,
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
--user
|
|
19
|
-
--entity
|
|
20
|
-
|
|
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
|
|
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.,
|
|
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
|
|
23
|
-
desc: "
|
|
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
|