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,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record/migration"
5
+ require_relative "../lib/plutonium_generators"
6
+
7
+ module Pu
8
+ module Invites
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::ActiveRecord::Generators::Migration
11
+ include PlutoniumGenerators::Generator
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ desc "Install user invites package"
16
+
17
+ class_option :entity_model, type: :string, default: "Entity",
18
+ desc: "The entity model name for scoping invites"
19
+
20
+ class_option :user_model, type: :string, default: "User",
21
+ desc: "The user model name"
22
+
23
+ class_option :membership_model, type: :string,
24
+ desc: "The membership model name (defaults to <Entity>User)"
25
+
26
+ class_option :roles, type: :array, default: %w[member admin],
27
+ desc: "Available roles for invitations"
28
+
29
+ class_option :rodauth, type: :string, default: "user",
30
+ desc: "Rodauth configuration name for signup integration"
31
+
32
+ class_option :enforce_domain, type: :boolean, default: false,
33
+ desc: "Require invited user email to match entity domain"
34
+
35
+ def validate_requirements
36
+ errors = []
37
+
38
+ unless File.exist?(Rails.root.join(entity_model_path))
39
+ errors << "Entity model not found: #{entity_model_path}"
40
+ end
41
+
42
+ unless File.exist?(Rails.root.join(entity_definition_path))
43
+ errors << "Entity definition not found: #{entity_definition_path}"
44
+ end
45
+
46
+ unless File.exist?(Rails.root.join(entity_policy_path))
47
+ errors << "Entity policy not found: #{entity_policy_path}"
48
+ end
49
+
50
+ unless File.exist?(Rails.root.join(user_definition_path))
51
+ errors << "User definition not found: #{user_definition_path}"
52
+ end
53
+
54
+ unless File.exist?(Rails.root.join(user_policy_path))
55
+ errors << "User policy not found: #{user_policy_path}"
56
+ end
57
+
58
+ if errors.any?
59
+ errors.each { |e| say_status :error, e, :red }
60
+ raise Thor::Error, "Required files missing. Ensure #{entity_model} and #{user_model} resources exist."
61
+ end
62
+ end
63
+
64
+ def create_package
65
+ generate "pu:pkg:package", "invites"
66
+ end
67
+
68
+ def create_user_invites_migration
69
+ migration_template "db/migrate/create_user_invites.rb",
70
+ "db/migrate/create_user_invites.rb"
71
+ end
72
+
73
+ def create_model
74
+ template "packages/invites/app/models/invites/user_invite.rb",
75
+ "packages/invites/app/models/invites/user_invite.rb"
76
+ end
77
+
78
+ def create_mailer
79
+ template "packages/invites/app/mailers/invites/user_invite_mailer.rb",
80
+ "packages/invites/app/mailers/invites/user_invite_mailer.rb"
81
+
82
+ template "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb",
83
+ "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb"
84
+
85
+ template "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb",
86
+ "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb"
87
+ end
88
+
89
+ def create_controllers
90
+ template "packages/invites/app/controllers/invites/user_invitations_controller.rb",
91
+ "packages/invites/app/controllers/invites/user_invitations_controller.rb"
92
+
93
+ template "packages/invites/app/controllers/invites/welcome_controller.rb",
94
+ "packages/invites/app/controllers/invites/welcome_controller.rb"
95
+ end
96
+
97
+ def create_views
98
+ %w[landing show signup error].each do |view|
99
+ template "packages/invites/app/views/invites/user_invitations/#{view}.html.erb",
100
+ "packages/invites/app/views/invites/user_invitations/#{view}.html.erb"
101
+ end
102
+
103
+ template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
104
+ "packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
105
+
106
+ template "packages/invites/app/views/layouts/invites/invitation.html.erb",
107
+ "packages/invites/app/views/layouts/invites/invitation.html.erb"
108
+ end
109
+
110
+ def create_interactions
111
+ template "packages/invites/app/interactions/invites/resend_invite_interaction.rb",
112
+ "packages/invites/app/interactions/invites/resend_invite_interaction.rb"
113
+
114
+ template "packages/invites/app/interactions/invites/cancel_invite_interaction.rb",
115
+ "packages/invites/app/interactions/invites/cancel_invite_interaction.rb"
116
+ end
117
+
118
+ def create_definition
119
+ template "packages/invites/app/definitions/invites/user_invite_definition.rb",
120
+ "packages/invites/app/definitions/invites/user_invite_definition.rb"
121
+ end
122
+
123
+ def create_policy
124
+ template "packages/invites/app/policies/invites/user_invite_policy.rb",
125
+ "packages/invites/app/policies/invites/user_invite_policy.rb"
126
+ end
127
+
128
+ def add_entity_association
129
+ inject_into_file entity_model_path,
130
+ "has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n ",
131
+ before: "# add has_many associations above."
132
+ end
133
+
134
+ def create_entity_interaction
135
+ template "app/interactions/invite_user_interaction.rb",
136
+ "app/interactions/#{entity_table}/invite_user_interaction.rb"
137
+ end
138
+
139
+ def add_entity_action
140
+ inject_into_file entity_definition_path,
141
+ " action :invite_user, interaction: #{entity_model}::InviteUserInteraction, category: :secondary\n",
142
+ after: /class #{entity_model}Definition < .+\n/
143
+ end
144
+
145
+ def add_entity_policy
146
+ inject_into_file entity_policy_path,
147
+ "def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
148
+ before: "# Core attributes"
149
+ end
150
+
151
+ def create_user_interaction
152
+ template "app/interactions/user_invite_user_interaction.rb",
153
+ "app/interactions/#{user_table}/invite_user_interaction.rb"
154
+ end
155
+
156
+ def add_user_action
157
+ inject_into_file user_definition_path,
158
+ " action :invite_user, interaction: #{user_model}::InviteUserInteraction, category: :primary\n",
159
+ after: /class #{user_model}Definition < .+\n/
160
+ end
161
+
162
+ def add_user_policy
163
+ inject_into_file user_policy_path,
164
+ "def invite_user?\n user.is_a?(Admin) || current_membership&.admin?\n end\n\n ",
165
+ before: "# Core attributes"
166
+ end
167
+
168
+ def add_resource_policy_helper
169
+ resource_policy_path = "app/policies/resource_policy.rb"
170
+
171
+ return unless File.exist?(Rails.root.join(resource_policy_path))
172
+
173
+ file_content = File.read(Rails.root.join(resource_policy_path))
174
+
175
+ helper_code = <<-RUBY
176
+ def current_membership
177
+ return unless entity_scope && user
178
+
179
+ cache(entity_scope, user, :current_membership) { #{membership_model}.find_by(#{entity_table}: entity_scope, user: user) }
180
+ end
181
+ RUBY
182
+
183
+ if file_content.include?("private\n")
184
+ inject_into_file resource_policy_path,
185
+ "\n#{helper_code}",
186
+ after: "private\n"
187
+ else
188
+ inject_into_file resource_policy_path,
189
+ "\n private\n\n#{helper_code}",
190
+ before: /^end\s*\z/
191
+ end
192
+ end
193
+
194
+ def add_routes
195
+ route_code = <<-RUBY
196
+
197
+ # User invitation routes (public, unauthenticated)
198
+ scope module: :invites do
199
+ get "welcome", to: "welcome#index", as: :invites_welcome
200
+ get "invitations/:token", to: "user_invitations#show", as: :invitation
201
+ post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
202
+ get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
203
+ post "invitations/:token/signup", to: "user_invitations#signup"
204
+ end
205
+ RUBY
206
+
207
+ inject_into_file "config/routes.rb",
208
+ route_code,
209
+ before: /^end\s*\z/
210
+ end
211
+
212
+ def configure_rodauth
213
+ return unless rodauth?
214
+
215
+ rodauth_file = "app/rodauth/#{rodauth_config}_rodauth_plugin.rb"
216
+
217
+ unless File.exist?(Rails.root.join(rodauth_file))
218
+ say_status :skip, "Rodauth plugin not found: #{rodauth_file}", :yellow
219
+ return
220
+ end
221
+
222
+ file_content = File.read(Rails.root.join(rodauth_file))
223
+
224
+ # Check if already configured
225
+ if file_content.include?("after_welcome_redirect")
226
+ say_status :skip, "Rodauth already configured for invites", :yellow
227
+ return
228
+ end
229
+
230
+ # Check for existing after_login block (non-commented)
231
+ # Single-line: after_login { remember_login }
232
+ single_line_match = file_content.match(/^(\s*)after_login\s*\{\s*(.+?)\s*\}/)
233
+ # Multi-line: after_login do ... end
234
+ multi_line_match = file_content.match(/^(\s*)after_login\s+do\s*\n(.*?)\n(\s*)end/m)
235
+
236
+ if single_line_match
237
+ # Convert single-line block to multi-line with our code added
238
+ indent = single_line_match[1]
239
+ existing_code = single_line_match[2]
240
+ original_line = single_line_match[0]
241
+
242
+ new_block = <<~RUBY.chomp
243
+ #{indent}after_login do
244
+ #{indent} #{existing_code}
245
+ #{indent} session[:after_welcome_redirect] = session.delete(:login_redirect)
246
+ #{indent}end
247
+ RUBY
248
+
249
+ gsub_file rodauth_file, original_line, new_block
250
+ say_status :info, "Added session redirect to existing after_login block", :green
251
+ elsif multi_line_match
252
+ # Multi-line block - add our line before the end
253
+ indent = multi_line_match[1]
254
+ block_content = multi_line_match[2]
255
+ end_indent = multi_line_match[3]
256
+ original_block = multi_line_match[0]
257
+
258
+ new_block = <<~RUBY.chomp
259
+ #{indent}after_login do
260
+ #{block_content}
261
+ #{indent} session[:after_welcome_redirect] = session.delete(:login_redirect)
262
+ #{end_indent}end
263
+ RUBY
264
+
265
+ gsub_file rodauth_file, original_block, new_block
266
+ say_status :info, "Added session redirect to existing after_login block", :green
267
+ else
268
+ # Add new after_login block
269
+ after_login_code = <<-RUBY
270
+
271
+ # ==> User Invites - Move captured path to session for WelcomeController
272
+ after_login do
273
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
274
+ end
275
+ RUBY
276
+
277
+ inject_into_file rodauth_file,
278
+ after_login_code,
279
+ before: /^ end\s*\n/
280
+
281
+ say_status :info, "Added after_login block for invites", :green
282
+ end
283
+
284
+ # Add other config options if not present
285
+ unless file_content.include?("login_return_to_requested_location?")
286
+ inject_into_file rodauth_file,
287
+ "\n # Enable path capture so Rodauth stores the originally requested URL\n login_return_to_requested_location? true\n",
288
+ after: /login_redirect.*\n/
289
+ end
290
+
291
+ # Update login_redirect to /welcome
292
+ gsub_file rodauth_file,
293
+ /login_redirect\s+["']\/["']/,
294
+ 'login_redirect "/welcome"'
295
+ end
296
+
297
+ def show_instructions
298
+ readme "INSTRUCTIONS" if behavior == :invoke
299
+ end
300
+
301
+ private
302
+
303
+ def entity_model
304
+ options[:entity_model].camelize
305
+ end
306
+
307
+ def entity_table
308
+ options[:entity_model].underscore
309
+ end
310
+
311
+ def entity_model_path
312
+ "app/models/#{entity_table}.rb"
313
+ end
314
+
315
+ def entity_definition_path
316
+ "app/definitions/#{entity_table}_definition.rb"
317
+ end
318
+
319
+ def entity_policy_path
320
+ "app/policies/#{entity_table}_policy.rb"
321
+ end
322
+
323
+ def user_model
324
+ options[:user_model].camelize
325
+ end
326
+
327
+ def user_table
328
+ options[:user_model].underscore
329
+ end
330
+
331
+ def user_definition_path
332
+ "app/definitions/#{user_table}_definition.rb"
333
+ end
334
+
335
+ def user_policy_path
336
+ "app/policies/#{user_table}_policy.rb"
337
+ end
338
+
339
+ def membership_model
340
+ options[:membership_model] || "#{entity_model}User"
341
+ end
342
+
343
+ def roles
344
+ Array(options[:roles]).flat_map { |r| r.split(",") }.map(&:strip)
345
+ end
346
+
347
+ def roles_enum
348
+ roles.each_with_index.map { |r, i| "#{r}: #{i}" }.join(", ")
349
+ end
350
+
351
+ def rodauth_config
352
+ options[:rodauth]
353
+ end
354
+
355
+ def rodauth?
356
+ rodauth_config.present?
357
+ end
358
+
359
+ def enforce_domain?
360
+ options[:enforce_domain]
361
+ end
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,31 @@
1
+ Description:
2
+ Connect an invitable model to send user invites.
3
+ The invitable is a model that triggers invites and gets notified on acceptance.
4
+
5
+ Example:
6
+ rails g pu:invites:invitable Tenant
7
+ rails g pu:invites:invitable Tenant --role=tenant
8
+ rails g pu:invites:invitable TeamMember --role=member
9
+ rails g pu:invites:invitable Tenant --no-email-templates
10
+
11
+ This will create:
12
+ app/interactions/tenant/invite_user_interaction.rb
13
+ packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.html.erb
14
+ packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.text.erb
15
+
16
+ And inject:
17
+ - include Plutonium::Invites::Concerns::Invitable in the model
18
+ - action :invite_user in the definition
19
+ - invite_user? policy method
20
+
21
+ After generation, implement Tenant#on_invite_accepted(user):
22
+ def on_invite_accepted(user)
23
+ update!(user: user)
24
+ end
25
+
26
+ Options:
27
+ --role=ROLE Role to assign to invited users (default: member)
28
+ --user-model=NAME User model name (default: User)
29
+ --membership-model=NAME Membership model (default: EntityUser)
30
+ --dest=PACKAGE Destination package (default: main_app)
31
+ --[no-]email-templates Generate custom email templates (default: true)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require_relative "../lib/plutonium_generators"
5
+
6
+ module Pu
7
+ module Invites
8
+ class InvitableGenerator < ::Rails::Generators::Base
9
+ include PlutoniumGenerators::Generator
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Connect an invitable model to send user invites"
14
+
15
+ argument :model, type: :string,
16
+ desc: "The invitable model (e.g., Tenant, TeamMember)"
17
+
18
+ class_option :role, type: :string, default: "member",
19
+ desc: "Role to assign to invited users"
20
+
21
+ class_option :user_model, type: :string, default: "User",
22
+ desc: "The user model name"
23
+
24
+ class_option :membership_model, type: :string,
25
+ desc: "The membership model name (defaults to EntityUser)"
26
+
27
+ class_option :dest, type: :string, default: "main_app",
28
+ desc: "Destination package for the interaction"
29
+
30
+ class_option :email_templates, type: :boolean, default: true,
31
+ desc: "Generate custom email templates for this invitable"
32
+
33
+ def validate_requirements
34
+ errors = []
35
+
36
+ unless File.exist?(Rails.root.join(model_file_path))
37
+ errors << "Model file not found: #{model_file_path}"
38
+ end
39
+
40
+ unless File.exist?(Rails.root.join(definition_file_path))
41
+ errors << "Definition file not found: #{definition_file_path}"
42
+ end
43
+
44
+ unless File.exist?(Rails.root.join(policy_file_path))
45
+ errors << "Policy file not found: #{policy_file_path}"
46
+ end
47
+
48
+ if errors.any?
49
+ errors.each { |e| say_status :error, e, :red }
50
+ raise Thor::Error, "Required files missing. Ensure #{model_class} resource exists."
51
+ end
52
+ end
53
+
54
+ def create_interaction
55
+ template "invitable/invite_user_interaction.rb",
56
+ interaction_path
57
+ end
58
+
59
+ def add_invitable_concern
60
+ inject_into_file model_file_path,
61
+ "include Plutonium::Invites::Concerns::Invitable\n ",
62
+ before: "# add concerns above."
63
+ end
64
+
65
+ def add_definition_action
66
+ inject_into_file definition_file_path,
67
+ " action :invite_user, interaction: #{model_class}::InviteUserInteraction, category: :secondary\n",
68
+ after: /class #{model_class}Definition < .+\n/
69
+ end
70
+
71
+ def add_policy_method
72
+ inject_into_file policy_file_path,
73
+ "def invite_user?\n record.can_invite_user?\n end\n\n ",
74
+ before: "# Core attributes"
75
+ end
76
+
77
+ def create_email_templates
78
+ return unless options[:email_templates]
79
+
80
+ template "invitable/invitation.html.erb",
81
+ "packages/invites/app/views/invites/user_invite_mailer/invitation_#{model_table}.html.erb"
82
+
83
+ template "invitable/invitation.text.erb",
84
+ "packages/invites/app/views/invites/user_invite_mailer/invitation_#{model_table}.text.erb"
85
+ end
86
+
87
+ def show_instructions
88
+ say "\n"
89
+ say "Connected #{model_class} as invitable!", :green
90
+ say "\n"
91
+ say "Implement #{model_class}#on_invite_accepted(user) to handle post-acceptance logic."
92
+ say "\n"
93
+ end
94
+
95
+ private
96
+
97
+ def model_class
98
+ model.camelize
99
+ end
100
+
101
+ def model_table
102
+ model.underscore
103
+ end
104
+
105
+ def user_model
106
+ options[:user_model].camelize
107
+ end
108
+
109
+ def user_table
110
+ options[:user_model].underscore
111
+ end
112
+
113
+ def model_file_path
114
+ "app/models/#{model_table}.rb"
115
+ end
116
+
117
+ def definition_file_path
118
+ "app/definitions/#{model_table}_definition.rb"
119
+ end
120
+
121
+ def policy_file_path
122
+ "app/policies/#{model_table}_policy.rb"
123
+ end
124
+
125
+ def role
126
+ options[:role]
127
+ end
128
+
129
+ def membership_model
130
+ options[:membership_model] || "EntityUser"
131
+ end
132
+
133
+ def interaction_path
134
+ dest = options[:dest]&.underscore
135
+ if dest == "main_app"
136
+ "app/interactions/#{model_table}/invite_user_interaction.rb"
137
+ else
138
+ "packages/#{dest}/app/interactions/#{dest}/#{model_table}/invite_user_interaction.rb"
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,22 @@
1
+
2
+ ===============================================================================
3
+
4
+ User invites package installed successfully!
5
+
6
+ Next steps:
7
+
8
+ 1. Run migrations:
9
+ rails db:migrate
10
+
11
+ 2. Register Invites::UserInvite in your portal routes:
12
+ # packages/<portal>/config/routes.rb
13
+ register_resource ::Invites::UserInvite
14
+
15
+ 3. (Optional) Connect invitable models that trigger invites:
16
+ rails g pu:invites:invitable Tenant
17
+ rails g pu:invites:invitable TeamMember --role=member
18
+
19
+ Then implement on_invite_accepted(user) in your invitable model.
20
+
21
+ ===============================================================================
22
+
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
+ include Plutonium::Invites::Concerns::InviteUser
5
+
6
+ attribute :role
7
+ input :role, as: :select, choices: Invites::UserInvite.roles.keys
8
+ <% if membership_model != "EntityUser" || user_model != "User" -%>
9
+
10
+ private
11
+ <% end -%>
12
+ <% if membership_model != "EntityUser" -%>
13
+
14
+ def membership_class
15
+ <%= membership_model %>
16
+ end
17
+ <% end -%>
18
+ <% if user_model != "User" -%>
19
+
20
+ def user_class
21
+ <%= user_model %>
22
+ end
23
+ <% end -%>
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interaction
4
+ include Plutonium::Invites::Concerns::InviteUser
5
+
6
+ attribute :role
7
+ input :role, as: :select, choices: Invites::UserInvite.roles.keys
8
+
9
+ private
10
+
11
+ def entity
12
+ current_entity
13
+ end
14
+ <% if membership_model != "EntityUser" -%>
15
+
16
+ def membership_class
17
+ <%= membership_model %>
18
+ end
19
+ <% end -%>
20
+ <% if user_model != "User" -%>
21
+
22
+ def user_class
23
+ <%= user_model %>
24
+ end
25
+ <% end -%>
26
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :user_invites do |t|
6
+ # Entity association
7
+ t.belongs_to :<%= entity_table %>, null: false, foreign_key: true
8
+
9
+ # Invitation details
10
+ t.string :email, null: false
11
+ t.text :token, null: false
12
+ t.integer :role, null: false, default: 0
13
+ t.integer :state, null: false, default: 0
14
+
15
+ # Timestamps
16
+ t.datetime :expires_at
17
+ t.datetime :accepted_at
18
+
19
+ # Who sent the invite (polymorphic for flexibility)
20
+ t.belongs_to :invited_by, null: false, polymorphic: true
21
+
22
+ # User who accepted (filled after acceptance)
23
+ t.belongs_to :<%= user_table %>, null: true, foreign_key: true
24
+
25
+ # Invitable: model that triggered this invite (optional, polymorphic)
26
+ t.belongs_to :invitable, null: true, polymorphic: true
27
+
28
+ # Additional data
29
+ t.jsonb :metadata, default: {}
30
+
31
+ t.timestamps
32
+
33
+ # Unique token index
34
+ t.index :token, unique: true
35
+
36
+ # Only one pending invite per email per entity
37
+ t.index [:<%= entity_table %>_id, :email], unique: true, where: "state = 0",
38
+ name: "index_user_invites_on_entity_email_pending"
39
+
40
+ # Only one pending invite per invitable (when invitable is present)
41
+ t.index [:invitable_type, :invitable_id],
42
+ unique: true,
43
+ where: "state = 0 AND invitable_id IS NOT NULL",
44
+ name: "index_user_invites_on_invitable_pending"
45
+ end
46
+ end
47
+ end