plutonium 0.39.2 → 0.40.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/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
- data/.claude/skills/plutonium-controller/SKILL.md +5 -9
- data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
- data/.claude/skills/plutonium-installation/SKILL.md +9 -7
- data/.claude/skills/plutonium-invites/SKILL.md +363 -0
- data/.claude/skills/plutonium-package/SKILL.md +2 -1
- data/.claude/skills/plutonium-portal/SKILL.md +30 -16
- data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
- data/CHANGELOG.md +43 -0
- data/app/assets/plutonium.css +1 -1
- data/config/initializers/sqlite_alias.rb +8 -8
- data/docs/.vitepress/config.ts +1 -0
- data/docs/getting-started/tutorial/07-author-portal.md +1 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
- data/docs/guides/adding-resources.md +10 -0
- data/docs/guides/authentication.md +15 -8
- data/docs/guides/creating-packages.md +13 -8
- data/docs/guides/index.md +2 -0
- data/docs/guides/search-filtering.md +8 -3
- data/docs/guides/user-invites.md +497 -0
- data/docs/public/templates/base.rb +5 -1
- data/docs/public/templates/lite.rb +42 -0
- data/docs/public/templates/pluton8.rb +7 -2
- data/docs/reference/controller/index.md +12 -7
- data/docs/reference/definition/query.md +12 -3
- data/docs/reference/generators/index.md +70 -10
- data/docs/reference/portal/index.md +22 -11
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
- data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
- data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
- data/lib/generators/pu/invites/USAGE +27 -0
- data/lib/generators/pu/invites/install_generator.rb +364 -0
- data/lib/generators/pu/invites/invitable/USAGE +31 -0
- data/lib/generators/pu/invites/invitable_generator.rb +143 -0
- data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
- data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
- data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
- data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
- data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
- data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
- data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
- data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
- data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
- data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
- data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
- data/lib/generators/pu/pkg/portal/USAGE +8 -2
- data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
- data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
- data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
- data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
- data/lib/generators/pu/res/conn/USAGE +5 -0
- data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
- data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
- data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
- data/lib/generators/pu/rodauth/account_generator.rb +36 -11
- data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
- data/lib/generators/pu/rodauth/install_generator.rb +1 -8
- data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
- data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
- data/lib/generators/pu/saas/USAGE +22 -0
- data/lib/generators/pu/saas/entity/USAGE +19 -0
- data/lib/generators/pu/saas/entity_generator.rb +55 -0
- data/lib/generators/pu/saas/membership/USAGE +25 -0
- data/lib/generators/pu/saas/membership_generator.rb +165 -0
- data/lib/generators/pu/saas/setup/USAGE +27 -0
- data/lib/generators/pu/saas/setup_generator.rb +98 -0
- data/lib/generators/pu/saas/user/USAGE +21 -0
- data/lib/generators/pu/saas/user_generator.rb +66 -0
- data/lib/plutonium/definition/base.rb +3 -1
- data/lib/plutonium/definition/scoping.rb +20 -0
- data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
- data/lib/plutonium/invites/concerns/invitable.rb +98 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
- data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
- data/lib/plutonium/invites/controller.rb +226 -0
- data/lib/plutonium/invites/pending_invite_check.rb +76 -0
- data/lib/plutonium/invites.rb +6 -0
- data/lib/plutonium/resource/controllers/queryable.rb +4 -0
- data/lib/plutonium/resource/query_object.rb +3 -5
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- metadata +64 -7
- data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
- data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
- data/public/plutonium-assets/plutonium-logo-original.png +0 -0
- data/public/plutonium-assets/plutonium-logo-white.png +0 -0
- data/public/plutonium-assets/plutonium-logo.png +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
SaaS generators for multi-tenant applications with user accounts, entities, and memberships.
|
|
3
|
+
|
|
4
|
+
Generators:
|
|
5
|
+
pu:saas:setup Complete SaaS setup (user + entity + membership)
|
|
6
|
+
pu:saas:user SaaS user account with Rodauth
|
|
7
|
+
pu:saas:entity Entity/organization model
|
|
8
|
+
pu:saas:membership Membership join table linking users to entities
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
rails g pu:saas:setup --user Customer --entity Organization
|
|
12
|
+
|
|
13
|
+
This creates:
|
|
14
|
+
- Customer user account with Rodauth authentication
|
|
15
|
+
- Organization entity model with unique name
|
|
16
|
+
- OrganizationCustomer membership model with role enum
|
|
17
|
+
|
|
18
|
+
See individual generator help for more options:
|
|
19
|
+
rails g pu:saas:setup --help
|
|
20
|
+
rails g pu:saas:user --help
|
|
21
|
+
rails g pu:saas:entity --help
|
|
22
|
+
rails g pu:saas:membership --help
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generate a SaaS entity model (organization/tenant).
|
|
3
|
+
Creates an entity with a unique name field for multi-tenant applications.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails g pu:saas:entity Organization
|
|
7
|
+
rails g pu:saas:entity Organization --dest=main_app
|
|
8
|
+
rails g pu:saas:entity Company --extra-attributes=slug:string,domain:string
|
|
9
|
+
|
|
10
|
+
This creates:
|
|
11
|
+
app/models/organization.rb
|
|
12
|
+
app/controllers/organizations_controller.rb
|
|
13
|
+
app/policies/organization_policy.rb
|
|
14
|
+
app/definitions/organization_definition.rb
|
|
15
|
+
db/migrate/XXX_create_organizations.rb (with unique index on name)
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--extra-attributes Additional model attributes (name:string is always included)
|
|
19
|
+
--dest Destination package (default: main_app)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/named_base"
|
|
4
|
+
require_relative "../lib/plutonium_generators"
|
|
5
|
+
|
|
6
|
+
module Pu
|
|
7
|
+
module Saas
|
|
8
|
+
class EntityGenerator < ::Rails::Generators::NamedBase
|
|
9
|
+
include PlutoniumGenerators::Generator
|
|
10
|
+
|
|
11
|
+
desc "Generate a SaaS entity model (organization/tenant)"
|
|
12
|
+
|
|
13
|
+
class_option :extra_attributes, type: :array, default: [],
|
|
14
|
+
desc: "Additional attributes for the entity model"
|
|
15
|
+
|
|
16
|
+
def start
|
|
17
|
+
generate_entity_resource
|
|
18
|
+
add_unique_index_to_migration
|
|
19
|
+
rescue => e
|
|
20
|
+
exception "#{self.class} failed:", e
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def generate_entity_resource
|
|
26
|
+
invoke "pu:res:scaffold", [normalized_name, *entity_attributes],
|
|
27
|
+
dest: selected_destination_feature,
|
|
28
|
+
model: true,
|
|
29
|
+
force: options[:force],
|
|
30
|
+
skip: options[:skip]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_unique_index_to_migration
|
|
34
|
+
migration_dir = File.join("db", "migrate")
|
|
35
|
+
migration_file = Dir[File.join(migration_dir, "*_create_#{normalized_name.pluralize}.rb")].first
|
|
36
|
+
|
|
37
|
+
return unless migration_file && File.exist?(migration_file)
|
|
38
|
+
|
|
39
|
+
insert_into_file migration_file,
|
|
40
|
+
indent("add_index :#{normalized_name.pluralize}, :name, unique: true\n", 4),
|
|
41
|
+
before: /^ end\s*$/
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def entity_attributes
|
|
45
|
+
["name:string", *Array(options[:extra_attributes])]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def normalized_name = name.underscore
|
|
49
|
+
|
|
50
|
+
def selected_destination_feature
|
|
51
|
+
feature_option :dest, prompt: "Select destination feature"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generate a SaaS membership model linking users to entities.
|
|
3
|
+
Creates a join table with role enum and bidirectional associations.
|
|
4
|
+
|
|
5
|
+
Requires both user and entity models to exist first.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
rails g pu:saas:membership --user Customer --entity Organization
|
|
9
|
+
rails g pu:saas:membership --user Customer --entity Organization --roles=member,admin,owner
|
|
10
|
+
rails g pu:saas:membership --user Customer --entity Organization --extra-attributes=joined_at:datetime
|
|
11
|
+
|
|
12
|
+
This creates:
|
|
13
|
+
app/models/organization_customer.rb (with role enum and uniqueness validation)
|
|
14
|
+
db/migrate/XXX_create_organization_customers.rb (with unique index)
|
|
15
|
+
|
|
16
|
+
And adds to existing models:
|
|
17
|
+
Customer: has_many :organization_customers, has_many :organizations
|
|
18
|
+
Organization: has_many :organization_customers, has_many :customers
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--user User model name (required)
|
|
22
|
+
--entity Entity model name (required)
|
|
23
|
+
--roles Comma-separated roles (default: member,owner)
|
|
24
|
+
--extra-attributes Additional model attributes
|
|
25
|
+
--dest Destination package (default: main_app)
|
|
@@ -0,0 +1,165 @@
|
|
|
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 MembershipGenerator < ::Rails::Generators::Base
|
|
9
|
+
include PlutoniumGenerators::Generator
|
|
10
|
+
|
|
11
|
+
desc "Generate a SaaS membership model linking users to entities"
|
|
12
|
+
|
|
13
|
+
class_option :user, type: :string, required: true,
|
|
14
|
+
desc: "The user model name (e.g., Customer)"
|
|
15
|
+
|
|
16
|
+
class_option :entity, type: :string, required: true,
|
|
17
|
+
desc: "The entity model name (e.g., Organization)"
|
|
18
|
+
|
|
19
|
+
class_option :roles, type: :array, default: %w[member owner],
|
|
20
|
+
desc: "Available roles for memberships"
|
|
21
|
+
|
|
22
|
+
class_option :extra_attributes, type: :array, default: [],
|
|
23
|
+
desc: "Additional attributes for the membership model"
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
validate_models_exist!
|
|
27
|
+
generate_membership_model
|
|
28
|
+
add_unique_index_to_migration
|
|
29
|
+
add_default_to_role_column
|
|
30
|
+
add_role_enum_to_model
|
|
31
|
+
add_unique_validation_to_model
|
|
32
|
+
add_associations_to_models
|
|
33
|
+
rescue => e
|
|
34
|
+
exception "#{self.class} failed:", e
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_models_exist!
|
|
40
|
+
user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
|
|
41
|
+
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
42
|
+
|
|
43
|
+
unless File.exist?(user_model_path)
|
|
44
|
+
raise "User model '#{normalized_user_name}' does not exist at #{user_model_path}. Please create it first with: rails g pu:saas:user #{options[:user]}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless File.exist?(entity_model_path)
|
|
48
|
+
raise "Entity model '#{normalized_entity_name}' does not exist at #{entity_model_path}. Please create it first with: rails g pu:saas:entity #{options[:entity]}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def generate_membership_model
|
|
53
|
+
invoke "pu:res:model", [membership_model_name, *membership_attributes],
|
|
54
|
+
dest: selected_destination_feature,
|
|
55
|
+
force: options[:force],
|
|
56
|
+
skip: options[:skip]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_unique_index_to_migration
|
|
60
|
+
migration_file = find_migration_file
|
|
61
|
+
|
|
62
|
+
return unless migration_file
|
|
63
|
+
|
|
64
|
+
insert_into_file migration_file,
|
|
65
|
+
indent("add_index :#{membership_table_name}, [:#{normalized_entity_name}_id, :#{normalized_user_name}_id], unique: true\n", 4),
|
|
66
|
+
before: /^ end\s*$/
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_default_to_role_column
|
|
70
|
+
migration_file = find_migration_file
|
|
71
|
+
|
|
72
|
+
return unless migration_file
|
|
73
|
+
|
|
74
|
+
gsub_file migration_file,
|
|
75
|
+
/t\.integer :role, null: false/,
|
|
76
|
+
"t.integer :role, null: false, default: 0"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def add_role_enum_to_model
|
|
80
|
+
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
81
|
+
|
|
82
|
+
return unless File.exist?(model_file)
|
|
83
|
+
|
|
84
|
+
enum_definition = "enum :role, #{roles_enum}\n"
|
|
85
|
+
insert_into_file model_file, indent(enum_definition, 2), before: /^\s*# add enums above\./
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_unique_validation_to_model
|
|
89
|
+
model_file = File.join("app", "models", "#{membership_model_name}.rb")
|
|
90
|
+
|
|
91
|
+
return unless File.exist?(model_file)
|
|
92
|
+
|
|
93
|
+
validation = "validates :#{normalized_user_name}, uniqueness: {scope: :#{normalized_entity_name}_id, message: \"is already a member of this #{normalized_entity_name.humanize.downcase}\"}\n"
|
|
94
|
+
insert_into_file model_file, indent(validation, 2), before: /^\s*# add validations above\./
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def add_associations_to_models
|
|
98
|
+
add_association_to_entity_model
|
|
99
|
+
add_association_to_user_model
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def add_association_to_entity_model
|
|
103
|
+
entity_model_path = File.join("app", "models", "#{normalized_entity_name}.rb")
|
|
104
|
+
|
|
105
|
+
return unless File.exist?(entity_model_path)
|
|
106
|
+
|
|
107
|
+
associations = <<~RUBY
|
|
108
|
+
has_many :#{membership_table_name}, dependent: :destroy
|
|
109
|
+
has_many :#{normalized_user_name.pluralize}, through: :#{membership_table_name}
|
|
110
|
+
RUBY
|
|
111
|
+
insert_into_file entity_model_path, indent(associations, 2), before: /# add has_many associations above\.\n/
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def add_association_to_user_model
|
|
115
|
+
user_model_path = File.join("app", "models", "#{normalized_user_name}.rb")
|
|
116
|
+
|
|
117
|
+
return unless File.exist?(user_model_path)
|
|
118
|
+
|
|
119
|
+
associations = <<~RUBY
|
|
120
|
+
has_many :#{membership_table_name}, dependent: :destroy
|
|
121
|
+
has_many :#{normalized_entity_name.pluralize}, through: :#{membership_table_name}
|
|
122
|
+
RUBY
|
|
123
|
+
insert_into_file user_model_path, indent(associations, 2), before: /# add has_many associations above\.\n/
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def find_migration_file
|
|
127
|
+
migration_dir = File.join("db", "migrate")
|
|
128
|
+
Dir[File.join(migration_dir, "*_create_#{membership_table_name}.rb")].first
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def membership_model_name
|
|
132
|
+
"#{normalized_entity_name}_#{normalized_user_name}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def membership_table_name
|
|
136
|
+
membership_model_name.pluralize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def membership_attributes
|
|
140
|
+
[
|
|
141
|
+
"#{normalized_entity_name}:references",
|
|
142
|
+
"#{normalized_user_name}:references",
|
|
143
|
+
"role:integer",
|
|
144
|
+
*Array(options[:extra_attributes])
|
|
145
|
+
]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def normalized_user_name = options[:user].underscore
|
|
149
|
+
|
|
150
|
+
def normalized_entity_name = options[:entity].underscore
|
|
151
|
+
|
|
152
|
+
def roles
|
|
153
|
+
Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def roles_enum
|
|
157
|
+
roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def selected_destination_feature
|
|
161
|
+
feature_option :dest, prompt: "Select destination feature"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
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
|
|
11
|
+
|
|
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
|
|
16
|
+
|
|
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)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Rodauth::Rails)
|
|
4
|
+
|
|
5
|
+
require "rails/generators/base"
|
|
6
|
+
require_relative "../lib/plutonium_generators"
|
|
7
|
+
|
|
8
|
+
module Pu
|
|
9
|
+
module Saas
|
|
10
|
+
class SetupGenerator < ::Rails::Generators::Base
|
|
11
|
+
include PlutoniumGenerators::Generator
|
|
12
|
+
|
|
13
|
+
desc "Generate a complete SaaS setup with user, entity, and membership"
|
|
14
|
+
|
|
15
|
+
class_option :user, type: :string, required: true,
|
|
16
|
+
desc: "The user model name (e.g., Customer)"
|
|
17
|
+
|
|
18
|
+
class_option :entity, type: :string, required: true,
|
|
19
|
+
desc: "The entity model name (e.g., Organization)"
|
|
20
|
+
|
|
21
|
+
class_option :allow_signup, type: :boolean, default: true,
|
|
22
|
+
desc: "Whether to allow users to sign up to the platform"
|
|
23
|
+
|
|
24
|
+
class_option :roles, type: :array, default: %w[member owner],
|
|
25
|
+
desc: "Available roles for memberships"
|
|
26
|
+
|
|
27
|
+
class_option :skip_entity, type: :boolean, default: false,
|
|
28
|
+
desc: "Skip entity model generation"
|
|
29
|
+
|
|
30
|
+
class_option :skip_membership, type: :boolean, default: false,
|
|
31
|
+
desc: "Skip membership model generation"
|
|
32
|
+
|
|
33
|
+
class_option :user_attributes, type: :array, default: [],
|
|
34
|
+
desc: "Additional attributes for the user model (e.g., name:string)"
|
|
35
|
+
|
|
36
|
+
class_option :entity_attributes, type: :array, default: [],
|
|
37
|
+
desc: "Additional attributes for the entity model"
|
|
38
|
+
|
|
39
|
+
class_option :membership_attributes, type: :array, default: [],
|
|
40
|
+
desc: "Additional attributes for the membership model"
|
|
41
|
+
|
|
42
|
+
def start
|
|
43
|
+
generate_user
|
|
44
|
+
generate_entity unless options[:skip_entity]
|
|
45
|
+
generate_membership unless options[:skip_membership]
|
|
46
|
+
rescue => e
|
|
47
|
+
exception "#{self.class} failed:", e
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def generate_user
|
|
53
|
+
# Use class-based invocation to avoid Thor's invoke caching
|
|
54
|
+
klass = Rails::Generators.find_by_namespace("pu:saas:user")
|
|
55
|
+
klass.new(
|
|
56
|
+
[options[:user]],
|
|
57
|
+
{
|
|
58
|
+
allow_signup: options[:allow_signup],
|
|
59
|
+
extra_attributes: options[:user_attributes],
|
|
60
|
+
force: options[:force],
|
|
61
|
+
skip: options[:skip]
|
|
62
|
+
}
|
|
63
|
+
).invoke_all
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def generate_entity
|
|
67
|
+
# Use class-based invocation to avoid Thor's invoke caching
|
|
68
|
+
klass = Rails::Generators.find_by_namespace("pu:saas:entity")
|
|
69
|
+
klass.new(
|
|
70
|
+
[options[:entity]],
|
|
71
|
+
{
|
|
72
|
+
extra_attributes: options[:entity_attributes],
|
|
73
|
+
dest: options[:dest],
|
|
74
|
+
force: options[:force],
|
|
75
|
+
skip: options[:skip]
|
|
76
|
+
}
|
|
77
|
+
).invoke_all
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def generate_membership
|
|
81
|
+
# Use class-based invocation to avoid Thor's invoke caching
|
|
82
|
+
klass = Rails::Generators.find_by_namespace("pu:saas:membership")
|
|
83
|
+
klass.new(
|
|
84
|
+
[],
|
|
85
|
+
{
|
|
86
|
+
user: options[:user],
|
|
87
|
+
entity: options[:entity],
|
|
88
|
+
roles: options[:roles],
|
|
89
|
+
extra_attributes: options[:membership_attributes],
|
|
90
|
+
dest: options[:dest],
|
|
91
|
+
force: options[:force],
|
|
92
|
+
skip: options[:skip]
|
|
93
|
+
}
|
|
94
|
+
).invoke_all
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Generate a SaaS user account with Rodauth authentication.
|
|
3
|
+
Creates a user model with login, signup, password reset, and other auth features.
|
|
4
|
+
|
|
5
|
+
Example:
|
|
6
|
+
rails g pu:saas:user Customer
|
|
7
|
+
rails g pu:saas:user Customer --no-allow-signup
|
|
8
|
+
rails g pu:saas:user Customer --extra-attributes=name:string,phone:string
|
|
9
|
+
|
|
10
|
+
This creates:
|
|
11
|
+
app/models/customer.rb
|
|
12
|
+
app/rodauth/customer_rodauth_plugin.rb
|
|
13
|
+
app/controllers/rodauth/customer_controller.rb
|
|
14
|
+
app/controllers/customers_controller.rb
|
|
15
|
+
app/policies/customer_policy.rb
|
|
16
|
+
app/definitions/customer_definition.rb
|
|
17
|
+
db/migrate/XXX_create_rodauth_customer_*.rb
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--allow-signup Allow public registration (default: true)
|
|
21
|
+
--extra-attributes Additional model attributes (e.g., name:string)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Rodauth::Rails)
|
|
4
|
+
|
|
5
|
+
require "rails/generators/named_base"
|
|
6
|
+
require_relative "../lib/plutonium_generators"
|
|
7
|
+
|
|
8
|
+
module Pu
|
|
9
|
+
module Saas
|
|
10
|
+
class UserGenerator < ::Rails::Generators::NamedBase
|
|
11
|
+
include PlutoniumGenerators::Concerns::Logger
|
|
12
|
+
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
desc "Generate a SaaS user account with Rodauth integration"
|
|
16
|
+
|
|
17
|
+
class_option :allow_signup, type: :boolean, default: true,
|
|
18
|
+
desc: "Whether to allow users to sign up to the platform"
|
|
19
|
+
|
|
20
|
+
class_option :extra_attributes, type: :array, default: [],
|
|
21
|
+
desc: "Additional attributes to add to the account model (e.g., name:string)"
|
|
22
|
+
|
|
23
|
+
def start
|
|
24
|
+
generate_user_account
|
|
25
|
+
rescue => e
|
|
26
|
+
exception "#{self.class} failed:", e
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def generate_user_account
|
|
32
|
+
invoke "pu:rodauth:account", [name],
|
|
33
|
+
defaults: false,
|
|
34
|
+
**user_features,
|
|
35
|
+
extra_attributes: Array(options[:extra_attributes]),
|
|
36
|
+
force: options[:force],
|
|
37
|
+
skip: options[:skip],
|
|
38
|
+
lint: true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def user_features
|
|
42
|
+
features = %i[
|
|
43
|
+
login
|
|
44
|
+
remember
|
|
45
|
+
logout
|
|
46
|
+
create_account
|
|
47
|
+
verify_account
|
|
48
|
+
verify_account_grace_period
|
|
49
|
+
reset_password
|
|
50
|
+
reset_password_notify
|
|
51
|
+
change_login
|
|
52
|
+
verify_login_change
|
|
53
|
+
change_password
|
|
54
|
+
change_password_notify
|
|
55
|
+
case_insensitive_login
|
|
56
|
+
internal_request
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
features.delete(:create_account) unless options[:allow_signup]
|
|
60
|
+
features.map { |feature| [feature, true] }.to_h
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalized_name = name.underscore
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -13,7 +13,8 @@ module Plutonium
|
|
|
13
13
|
# field :name, as: :string
|
|
14
14
|
# input :email, as: :email
|
|
15
15
|
# filter :status, type: :select, collection: %w[active inactive]
|
|
16
|
-
# scope :active
|
|
16
|
+
# scope :active
|
|
17
|
+
# default_scope :active
|
|
17
18
|
# sorter :created_at
|
|
18
19
|
#
|
|
19
20
|
# def customize_fields
|
|
@@ -29,6 +30,7 @@ module Plutonium
|
|
|
29
30
|
include InheritableConfigAttr
|
|
30
31
|
include Actions
|
|
31
32
|
include Sorting
|
|
33
|
+
include Scoping
|
|
32
34
|
include Search
|
|
33
35
|
include NestedInputs
|
|
34
36
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Plutonium
|
|
2
|
+
module Definition
|
|
3
|
+
module Scoping
|
|
4
|
+
extend ActiveSupport::Concern
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
class_attribute :_default_scope, instance_writer: false, instance_predicate: false
|
|
8
|
+
|
|
9
|
+
def self.default_scope(name = nil)
|
|
10
|
+
self._default_scope = name.to_sym if name
|
|
11
|
+
_default_scope
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def default_scope
|
|
16
|
+
self.class._default_scope
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# CancelInvite provides the cancel invitation interaction logic.
|
|
7
|
+
#
|
|
8
|
+
# Include this concern in your cancel interaction and override methods
|
|
9
|
+
# as needed for customization.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class CancelInviteInteraction < Plutonium::Resource::Interaction
|
|
13
|
+
# include Plutonium::Invites::Concerns::CancelInvite
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
module CancelInvite
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
presents label: "Cancel Invitation", icon: "outline/x-circle"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute
|
|
24
|
+
unless resource.pending?
|
|
25
|
+
return failed(not_pending_message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
resource.cancelled!
|
|
29
|
+
succeed(resource).with_message(success_message)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def success_message
|
|
35
|
+
"Invitation cancelled"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def not_pending_message
|
|
39
|
+
"Can only cancel pending invitations"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Invites
|
|
5
|
+
module Concerns
|
|
6
|
+
# Invitable allows any model to trigger invites and be notified on acceptance.
|
|
7
|
+
#
|
|
8
|
+
# This pattern is useful when you have a profile or record that needs to
|
|
9
|
+
# invite a user and then connect itself to that user after acceptance.
|
|
10
|
+
#
|
|
11
|
+
# @example TenantProfile that invites users
|
|
12
|
+
# class TenantProfile < ApplicationRecord
|
|
13
|
+
# include Plutonium::Resource::Record
|
|
14
|
+
# include Plutonium::Invites::Concerns::Invitable
|
|
15
|
+
#
|
|
16
|
+
# belongs_to :entity
|
|
17
|
+
# belongs_to :user, optional: true
|
|
18
|
+
#
|
|
19
|
+
# def on_invite_accepted(user)
|
|
20
|
+
# update!(user: user)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example Creating an invite from an invitable
|
|
25
|
+
# tenant_profile.create_invite!(
|
|
26
|
+
# email: tenant_profile.email,
|
|
27
|
+
# entity: tenant_profile.entity,
|
|
28
|
+
# invited_by: current_user,
|
|
29
|
+
# role: :member,
|
|
30
|
+
# email_template: "tenant"
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
module Invitable
|
|
34
|
+
extend ActiveSupport::Concern
|
|
35
|
+
|
|
36
|
+
included do
|
|
37
|
+
# Association to the pending user invite for this record.
|
|
38
|
+
# Scoped to pending only - cancelled/expired/accepted invites are kept for audit.
|
|
39
|
+
has_one :user_invite, -> { pending }, class_name: "Invites::UserInvite", as: :invitable
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Create an invite for this invitable.
|
|
43
|
+
#
|
|
44
|
+
# If there's already a pending invite for this invitable, it will be
|
|
45
|
+
# destroyed and replaced with a new one.
|
|
46
|
+
#
|
|
47
|
+
# @param email [String] the email address to invite
|
|
48
|
+
# @param entity [Object] the entity to join
|
|
49
|
+
# @param invited_by [Object] the user creating the invite
|
|
50
|
+
# @param role [Symbol, String] the role to assign (default: nil, uses model default)
|
|
51
|
+
# @param email_template [String, nil] optional template type for email customization
|
|
52
|
+
# @return [Object] the created invite record
|
|
53
|
+
def create_invite!(email:, entity:, invited_by:, role: nil, email_template: nil)
|
|
54
|
+
# Cancel any existing pending invite first (association is already scoped to pending)
|
|
55
|
+
user_invite&.cancelled!
|
|
56
|
+
|
|
57
|
+
attrs = {
|
|
58
|
+
email: email,
|
|
59
|
+
entity: entity,
|
|
60
|
+
invited_by: invited_by,
|
|
61
|
+
email_template: email_template
|
|
62
|
+
}
|
|
63
|
+
attrs[:role] = role if role.present?
|
|
64
|
+
|
|
65
|
+
create_user_invite!(attrs)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if there's an active pending invite.
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean] true if there's a pending invite
|
|
71
|
+
def has_pending_invite?
|
|
72
|
+
user_invite.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Check if this invitable can receive an invitation.
|
|
76
|
+
#
|
|
77
|
+
# Override this method to customize the logic. The default implementation
|
|
78
|
+
# returns true if no user is attached and no pending invite exists.
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] true if invitation can be sent
|
|
81
|
+
def can_invite_user?
|
|
82
|
+
!user.present? && !has_pending_invite?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Called when the invited user accepts and joins the entity.
|
|
86
|
+
#
|
|
87
|
+
# Override this method in your model to handle the acceptance,
|
|
88
|
+
# typically to connect the invitable to the user.
|
|
89
|
+
#
|
|
90
|
+
# @param user [Object] the user who accepted the invite
|
|
91
|
+
# @raise [NotImplementedError] if not overridden
|
|
92
|
+
def on_invite_accepted(user)
|
|
93
|
+
raise NotImplementedError, "#{self.class.name} must implement #on_invite_accepted(user)"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|