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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-connect-resource/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-controller/SKILL.md +5 -9
  4. data/.claude/skills/plutonium-definition-query/SKILL.md +10 -2
  5. data/.claude/skills/plutonium-installation/SKILL.md +9 -7
  6. data/.claude/skills/plutonium-invites/SKILL.md +363 -0
  7. data/.claude/skills/plutonium-package/SKILL.md +2 -1
  8. data/.claude/skills/plutonium-portal/SKILL.md +30 -16
  9. data/.claude/skills/plutonium-rodauth/SKILL.md +111 -18
  10. data/CHANGELOG.md +43 -0
  11. data/app/assets/plutonium.css +1 -1
  12. data/config/initializers/sqlite_alias.rb +8 -8
  13. data/docs/.vitepress/config.ts +1 -0
  14. data/docs/getting-started/tutorial/07-author-portal.md +1 -0
  15. data/docs/getting-started/tutorial/08-customizing-ui.md +5 -2
  16. data/docs/guides/adding-resources.md +10 -0
  17. data/docs/guides/authentication.md +15 -8
  18. data/docs/guides/creating-packages.md +13 -8
  19. data/docs/guides/index.md +2 -0
  20. data/docs/guides/search-filtering.md +8 -3
  21. data/docs/guides/user-invites.md +497 -0
  22. data/docs/public/templates/base.rb +5 -1
  23. data/docs/public/templates/lite.rb +42 -0
  24. data/docs/public/templates/pluton8.rb +7 -2
  25. data/docs/reference/controller/index.md +12 -7
  26. data/docs/reference/definition/query.md +12 -3
  27. data/docs/reference/generators/index.md +70 -10
  28. data/docs/reference/portal/index.md +22 -11
  29. data/gemfiles/rails_7.gemfile.lock +1 -1
  30. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  31. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  32. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +31 -0
  33. data/lib/generators/pu/gem/active_shrine/templates/config/initializers/shrine.rb.tt +58 -0
  34. data/lib/generators/pu/gem/annotated/templates/lib/tasks/auto_annotate_models.rake +6 -1
  35. data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -0
  36. data/lib/generators/pu/invites/USAGE +27 -0
  37. data/lib/generators/pu/invites/install_generator.rb +364 -0
  38. data/lib/generators/pu/invites/invitable/USAGE +31 -0
  39. data/lib/generators/pu/invites/invitable_generator.rb +143 -0
  40. data/lib/generators/pu/invites/templates/INSTRUCTIONS +22 -0
  41. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +24 -0
  42. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +26 -0
  43. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +47 -0
  44. data/lib/generators/pu/invites/templates/invitable/invitation.html.erb.tt +45 -0
  45. data/lib/generators/pu/invites/templates/invitable/invitation.text.erb.tt +15 -0
  46. data/lib/generators/pu/invites/templates/invitable/invite_user_interaction.rb.tt +33 -0
  47. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +77 -0
  48. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +68 -0
  49. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +23 -0
  50. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/cancel_invite_interaction.rb.tt +7 -0
  51. data/lib/generators/pu/invites/templates/packages/invites/app/interactions/invites/resend_invite_interaction.rb.tt +7 -0
  52. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +34 -0
  53. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +41 -0
  54. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +33 -0
  55. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/error.html.erb.tt +24 -0
  56. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +40 -0
  57. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +39 -0
  58. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +49 -0
  59. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +45 -0
  60. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +15 -0
  61. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/welcome/pending_invitation.html.erb.tt +23 -0
  62. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +33 -0
  63. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +23 -2
  64. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +130 -0
  65. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +72 -0
  66. data/lib/generators/pu/lib/plutonium_generators/concerns/package_selector.rb +4 -2
  67. data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +7 -1
  68. data/lib/generators/pu/lite/litestream/litestream_generator.rb +105 -0
  69. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +88 -0
  70. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +14 -0
  71. data/lib/generators/pu/lite/setup/setup_generator.rb +54 -0
  72. data/lib/generators/pu/lite/solid_cable/solid_cable_generator.rb +65 -0
  73. data/lib/generators/pu/lite/solid_cache/solid_cache_generator.rb +66 -0
  74. data/lib/generators/pu/lite/solid_errors/solid_errors_generator.rb +61 -0
  75. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +107 -0
  76. data/lib/generators/pu/pkg/portal/USAGE +8 -2
  77. data/lib/generators/pu/pkg/portal/portal_generator.rb +11 -1
  78. data/lib/generators/pu/pkg/portal/templates/app/controllers/concerns/controller.rb.tt +2 -0
  79. data/lib/generators/pu/pkg/portal/templates/app/controllers/plutonium_controller.rb.tt +1 -0
  80. data/lib/generators/pu/pkg/portal/templates/app/controllers/resource_controller.rb.tt +7 -0
  81. data/lib/generators/pu/pkg/portal/templates/lib/engine.rb.tt +3 -0
  82. data/lib/generators/pu/res/conn/USAGE +5 -0
  83. data/lib/generators/pu/res/conn/conn_generator.rb +30 -4
  84. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +6 -3
  85. data/lib/generators/pu/res/scaffold/templates/policy.rb.tt +6 -6
  86. data/lib/generators/pu/rodauth/account_generator.rb +36 -11
  87. data/lib/generators/pu/rodauth/admin_generator.rb +55 -0
  88. data/lib/generators/pu/rodauth/install_generator.rb +1 -8
  89. data/lib/generators/pu/rodauth/templates/app/interactions/invite_admin_interaction.rb.tt +25 -0
  90. data/lib/generators/pu/rodauth/templates/app/models/account.rb.tt +6 -2
  91. data/lib/generators/pu/saas/USAGE +22 -0
  92. data/lib/generators/pu/saas/entity/USAGE +19 -0
  93. data/lib/generators/pu/saas/entity_generator.rb +55 -0
  94. data/lib/generators/pu/saas/membership/USAGE +25 -0
  95. data/lib/generators/pu/saas/membership_generator.rb +165 -0
  96. data/lib/generators/pu/saas/setup/USAGE +27 -0
  97. data/lib/generators/pu/saas/setup_generator.rb +98 -0
  98. data/lib/generators/pu/saas/user/USAGE +21 -0
  99. data/lib/generators/pu/saas/user_generator.rb +66 -0
  100. data/lib/plutonium/definition/base.rb +3 -1
  101. data/lib/plutonium/definition/scoping.rb +20 -0
  102. data/lib/plutonium/invites/concerns/cancel_invite.rb +44 -0
  103. data/lib/plutonium/invites/concerns/invitable.rb +98 -0
  104. data/lib/plutonium/invites/concerns/invite_token.rb +186 -0
  105. data/lib/plutonium/invites/concerns/invite_user.rb +147 -0
  106. data/lib/plutonium/invites/concerns/resend_invite.rb +66 -0
  107. data/lib/plutonium/invites/controller.rb +226 -0
  108. data/lib/plutonium/invites/pending_invite_check.rb +76 -0
  109. data/lib/plutonium/invites.rb +6 -0
  110. data/lib/plutonium/resource/controllers/queryable.rb +4 -0
  111. data/lib/plutonium/resource/query_object.rb +3 -5
  112. data/lib/plutonium/version.rb +1 -1
  113. data/package.json +1 -1
  114. metadata +64 -7
  115. data/lib/generators/pu/res/entity/entity_generator.rb +0 -158
  116. data/lib/generators/pu/rodauth/customer_generator.rb +0 -101
  117. data/public/plutonium-assets/plutonium-logo-original.png +0 -0
  118. data/public/plutonium-assets/plutonium-logo-white.png +0 -0
  119. 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, default: true
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