plutonium 0.48.0 → 0.49.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  3. data/CHANGELOG.md +38 -0
  4. data/app/assets/plutonium.js +73 -25
  5. data/app/assets/plutonium.js.map +3 -3
  6. data/app/assets/plutonium.min.js +29 -29
  7. data/app/assets/plutonium.min.js.map +3 -3
  8. data/app/views/plutonium/_flash.html.erb +1 -1
  9. data/config/initializers/pagy.rb +1 -1
  10. data/docs/guides/user-invites.md +64 -0
  11. data/docs/public/templates/plutonium.rb +3 -0
  12. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  13. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  14. data/gemfiles/rails_7.gemfile.lock +27 -1
  15. data/gemfiles/rails_8.0.gemfile.lock +27 -1
  16. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  17. data/lib/generators/pu/gem/actual_db_schema/actual_db_schema_generator.rb +24 -0
  18. data/lib/generators/pu/invites/install_generator.rb +136 -35
  19. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  20. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  21. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  22. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  23. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  24. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  25. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  26. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  27. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  28. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  29. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  30. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  31. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  32. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  33. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  34. data/lib/generators/pu/lib/plutonium_generators/concerns/configures_sqlite.rb +9 -3
  35. data/lib/generators/pu/lite/rails_pulse/rails_pulse_generator.rb +6 -3
  36. data/lib/generators/pu/lite/rails_pulse/templates/config/initializers/rails_pulse.rb.tt +18 -0
  37. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  38. data/lib/plutonium/core/controller.rb +10 -3
  39. data/lib/plutonium/engine.rb +1 -1
  40. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  41. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  42. data/lib/plutonium/invites/controller.rb +14 -1
  43. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  44. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  45. data/lib/plutonium/resource/policy.rb +23 -8
  46. data/lib/plutonium/rodauth/controller_methods.rb +5 -1
  47. data/lib/plutonium/ui/color_mode_selector.rb +7 -18
  48. data/lib/plutonium/ui/layout/rodauth_layout.rb +6 -0
  49. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  50. data/lib/plutonium/ui/table/components/pagy_info.rb +1 -1
  51. data/lib/plutonium/version.rb +1 -1
  52. data/package.json +1 -1
  53. data/plutonium.gemspec +16 -0
  54. data/src/js/controllers/color_mode_controller.js +41 -34
  55. data/src/js/controllers/flatpickr_controller.js +23 -0
  56. data/src/js/controllers/sidebar_controller.js +28 -1
  57. metadata +19 -2
@@ -0,0 +1,15 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-05-06-multi-invite-model-support.md",
3
+ "tasks": [
4
+ {"id": 1, "subject": "Task 1: PendingInviteCheck — multi-class support", "status": "completed"},
5
+ {"id": 2, "subject": "Task 2: Auto append_view_path in invites concerns + qualify flash partial", "status": "completed"},
6
+ {"id": 3, "subject": "Task 3: Plutonium::Invites::Controller — overridable invitation_path", "status": "completed"},
7
+ {"id": 4, "subject": "Task 4: install_generator — --invite_model option + naming helpers", "status": "completed"},
8
+ {"id": 5, "subject": "Task 5: Parameterize all templates by invite model name", "status": "completed", "blockedBy": [4]},
9
+ {"id": 6, "subject": "Task 6: Scope routes per invite type with idempotent re-run", "status": "completed", "blockedBy": [4]},
10
+ {"id": 7, "subject": "Task 7: Welcome controller — multi-class integration on re-run", "status": "completed", "blockedBy": [1, 4]},
11
+ {"id": 8, "subject": "Task 8: End-to-end dual-invocation generator test", "status": "completed", "blockedBy": [5, 6, 7]},
12
+ {"id": 9, "subject": "Task 9: Update skill + guide docs", "status": "completed", "blockedBy": [8]}
13
+ ],
14
+ "lastUpdated": "2026-05-06T14:00:00Z"
15
+ }
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.45.3)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -100,6 +100,8 @@ GEM
100
100
  minitest (>= 5.1)
101
101
  securerandom (>= 0.3)
102
102
  tzinfo (~> 2.0, >= 2.0.5)
103
+ addressable (2.9.0)
104
+ public_suffix (>= 2.0.2, < 8.0)
103
105
  ansi (1.5.0)
104
106
  appraisal (2.5.0)
105
107
  bundler
@@ -118,6 +120,15 @@ GEM
118
120
  bundler-audit (0.9.3)
119
121
  bundler (>= 1.2.0)
120
122
  thor (~> 1.0)
123
+ capybara (3.40.0)
124
+ addressable
125
+ matrix
126
+ mini_mime (>= 0.1.3)
127
+ nokogiri (~> 1.11)
128
+ rack (>= 1.6.0)
129
+ rack-test (>= 0.6.3)
130
+ regexp_parser (>= 1.5, < 3.0)
131
+ xpath (~> 3.2)
121
132
  cgi (0.5.1)
122
133
  chunky_png (1.4.0)
123
134
  combustion (1.5.0)
@@ -174,6 +185,7 @@ GEM
174
185
  net-pop
175
186
  net-smtp
176
187
  marcel (1.1.0)
188
+ matrix (0.4.3)
177
189
  mini_mime (1.1.5)
178
190
  minitest (6.0.2)
179
191
  drb (~> 2.0)
@@ -265,6 +277,7 @@ GEM
265
277
  psych (5.3.1)
266
278
  date
267
279
  stringio
280
+ public_suffix (7.0.5)
268
281
  puma (7.2.0)
269
282
  nio4r (~> 2.0)
270
283
  rabl (0.17.0)
@@ -325,6 +338,7 @@ GEM
325
338
  regexp_parser (2.11.3)
326
339
  reline (0.6.3)
327
340
  io-console (~> 0.5)
341
+ rexml (3.4.4)
328
342
  roda (3.102.0)
329
343
  rack
330
344
  rodauth (2.42.0)
@@ -362,7 +376,14 @@ GEM
362
376
  rubocop-ast (>= 1.47.1, < 2.0)
363
377
  ruby-next-core (1.2.0)
364
378
  ruby-progressbar (1.13.0)
379
+ rubyzip (3.2.2)
365
380
  securerandom (0.4.1)
381
+ selenium-webdriver (4.43.0)
382
+ base64 (~> 0.2)
383
+ logger (~> 1.4)
384
+ rexml (~> 3.2, >= 3.2.5)
385
+ rubyzip (>= 1.2.2, < 4.0)
386
+ websocket (~> 1.0)
366
387
  semantic_range (3.1.1)
367
388
  sequel (5.102.0)
368
389
  bigdecimal
@@ -419,11 +440,14 @@ GEM
419
440
  unicode-emoji (4.2.0)
420
441
  uri (1.1.1)
421
442
  useragent (0.16.11)
443
+ websocket (1.2.11)
422
444
  websocket-driver (0.8.0)
423
445
  base64
424
446
  websocket-extensions (>= 0.1.0)
425
447
  websocket-extensions (0.1.5)
426
448
  wisper (2.0.1)
449
+ xpath (3.2.0)
450
+ nokogiri (~> 1.8)
427
451
  yaml (0.4.0)
428
452
  zeitwerk (2.7.5)
429
453
 
@@ -442,6 +466,7 @@ DEPENDENCIES
442
466
  bcrypt
443
467
  brakeman
444
468
  bundle-audit
469
+ capybara
445
470
  combustion
446
471
  importmap-rails
447
472
  minitest
@@ -454,6 +479,7 @@ DEPENDENCIES
454
479
  rodauth-rails
455
480
  rotp
456
481
  rqrcode
482
+ selenium-webdriver
457
483
  sequel-activerecord_connection
458
484
  sqlite3
459
485
  standard
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.45.3)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -98,6 +98,8 @@ GEM
98
98
  securerandom (>= 0.3)
99
99
  tzinfo (~> 2.0, >= 2.0.5)
100
100
  uri (>= 0.13.1)
101
+ addressable (2.9.0)
102
+ public_suffix (>= 2.0.2, < 8.0)
101
103
  ansi (1.5.0)
102
104
  appraisal (2.5.0)
103
105
  bundler
@@ -116,6 +118,15 @@ GEM
116
118
  bundler-audit (0.9.3)
117
119
  bundler (>= 1.2.0)
118
120
  thor (~> 1.0)
121
+ capybara (3.40.0)
122
+ addressable
123
+ matrix
124
+ mini_mime (>= 0.1.3)
125
+ nokogiri (~> 1.11)
126
+ rack (>= 1.6.0)
127
+ rack-test (>= 0.6.3)
128
+ regexp_parser (>= 1.5, < 3.0)
129
+ xpath (~> 3.2)
119
130
  chunky_png (1.4.0)
120
131
  combustion (1.5.0)
121
132
  activesupport (>= 3.0.0)
@@ -164,6 +175,7 @@ GEM
164
175
  net-pop
165
176
  net-smtp
166
177
  marcel (1.1.0)
178
+ matrix (0.4.3)
167
179
  mini_mime (1.1.5)
168
180
  minitest (6.0.2)
169
181
  drb (~> 2.0)
@@ -241,6 +253,7 @@ GEM
241
253
  psych (5.3.1)
242
254
  date
243
255
  stringio
256
+ public_suffix (7.0.5)
244
257
  puma (7.2.0)
245
258
  nio4r (~> 2.0)
246
259
  rabl (0.17.0)
@@ -300,6 +313,7 @@ GEM
300
313
  regexp_parser (2.11.3)
301
314
  reline (0.6.3)
302
315
  io-console (~> 0.5)
316
+ rexml (3.4.4)
303
317
  roda (3.102.0)
304
318
  rack
305
319
  rodauth (2.42.0)
@@ -337,7 +351,14 @@ GEM
337
351
  rubocop-ast (>= 1.47.1, < 2.0)
338
352
  ruby-next-core (1.2.0)
339
353
  ruby-progressbar (1.13.0)
354
+ rubyzip (3.2.2)
340
355
  securerandom (0.4.1)
356
+ selenium-webdriver (4.43.0)
357
+ base64 (~> 0.2)
358
+ logger (~> 1.4)
359
+ rexml (~> 3.2, >= 3.2.5)
360
+ rubyzip (>= 1.2.2, < 4.0)
361
+ websocket (~> 1.0)
341
362
  semantic_range (3.1.1)
342
363
  sequel (5.102.0)
343
364
  bigdecimal
@@ -387,11 +408,14 @@ GEM
387
408
  unicode-emoji (4.2.0)
388
409
  uri (1.1.1)
389
410
  useragent (0.16.11)
411
+ websocket (1.2.11)
390
412
  websocket-driver (0.8.0)
391
413
  base64
392
414
  websocket-extensions (>= 0.1.0)
393
415
  websocket-extensions (0.1.5)
394
416
  wisper (2.0.1)
417
+ xpath (3.2.0)
418
+ nokogiri (~> 1.8)
395
419
  yaml (0.4.0)
396
420
  zeitwerk (2.7.5)
397
421
 
@@ -403,6 +427,7 @@ DEPENDENCIES
403
427
  bcrypt
404
428
  brakeman
405
429
  bundle-audit
430
+ capybara
406
431
  combustion
407
432
  importmap-rails
408
433
  minitest
@@ -415,6 +440,7 @@ DEPENDENCIES
415
440
  rodauth-rails
416
441
  rotp
417
442
  rqrcode
443
+ selenium-webdriver
418
444
  sequel-activerecord_connection
419
445
  sqlite3
420
446
  standard
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.47.0)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ module Gem
7
+ # Installs actual_db_schema, which tracks phantom migrations across git
8
+ # branches so switching branches with diverging migration sets doesn't
9
+ # leave db/schema.rb out of sync.
10
+ #
11
+ # https://github.com/share-group/actual_db_schema
12
+ class ActualDbSchemaGenerator < Rails::Generators::Base
13
+ include PlutoniumGenerators::Generator
14
+
15
+ desc "Install the actual_db_schema gem"
16
+
17
+ def start
18
+ bundle "actual_db_schema", group: %i[development test]
19
+ rescue => e
20
+ exception "#{self.class} failed:", e
21
+ end
22
+ end
23
+ end
24
+ end
@@ -21,6 +21,9 @@ module Pu
21
21
  class_option :user_model, type: :string, default: "User",
22
22
  desc: "The user model name"
23
23
 
24
+ class_option :invite_model, type: :string, default: nil,
25
+ desc: "The invite model class name. Defaults to <EntityModel><UserModel>Invite (e.g., OrganizationUserInvite). Override for separate flows in multi-entity apps."
26
+
24
27
  class_option :membership_model, type: :string,
25
28
  desc: "The membership model name (defaults to <Entity><User>)"
26
29
 
@@ -72,44 +75,80 @@ module Pu
72
75
 
73
76
  def create_user_invites_migration
74
77
  migration_template "db/migrate/create_user_invites.rb",
75
- "db/migrate/create_user_invites.rb"
78
+ "db/migrate/create_#{invite_table}.rb"
76
79
  end
77
80
 
78
81
  def create_model
79
82
  template "packages/invites/app/models/invites/user_invite.rb",
80
- "packages/invites/app/models/invites/user_invite.rb"
83
+ "packages/invites/app/models/invites/#{invite_underscore}.rb"
81
84
  end
82
85
 
83
86
  def create_mailer
84
87
  template "packages/invites/app/mailers/invites/user_invite_mailer.rb",
85
- "packages/invites/app/mailers/invites/user_invite_mailer.rb"
88
+ "packages/invites/app/mailers/invites/#{invite_underscore}_mailer.rb"
86
89
 
87
90
  template "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb",
88
- "packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb"
91
+ "packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.html.erb"
89
92
 
90
93
  template "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb",
91
- "packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb"
94
+ "packages/invites/app/views/invites/#{invite_underscore}_mailer/invitation.text.erb"
92
95
  end
93
96
 
94
97
  def create_controllers
95
98
  template "packages/invites/app/controllers/invites/user_invitations_controller.rb",
96
- "packages/invites/app/controllers/invites/user_invitations_controller.rb"
99
+ "packages/invites/app/controllers/invites/#{invitations_path}_controller.rb"
100
+
101
+ # Welcome controller is a one-shot — only generate if it doesn't exist yet.
102
+ welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
103
+ unless File.exist?(Rails.root.join(welcome_path))
104
+ template "packages/invites/app/controllers/invites/welcome_controller.rb",
105
+ welcome_path
106
+ end
107
+ end
108
+
109
+ def add_welcome_invite_class
110
+ welcome_path = "packages/invites/app/controllers/invites/welcome_controller.rb"
111
+ return unless File.exist?(Rails.root.join(welcome_path))
97
112
 
98
- template "packages/invites/app/controllers/invites/welcome_controller.rb",
99
- "packages/invites/app/controllers/invites/welcome_controller.rb"
113
+ content = File.read(Rails.root.join(welcome_path))
114
+ new_class = "::Invites::#{invite_model}"
115
+
116
+ # Already present? bail. Use a non-word lookahead so we don't match
117
+ # `::Invites::FunderInvite` when looking for `::Invites::Funder`.
118
+ return if content =~ /#{Regexp.escape(new_class)}(?!\w)/
119
+
120
+ # Find `def invite_classes` block; inject before the closing `]`.
121
+ injection = content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
122
+ before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
123
+ existing = list.strip
124
+ new_list = existing.empty? ? new_class : "#{existing.chomp(",").strip}, #{new_class}"
125
+ "#{before}#{new_list}#{after}"
126
+ end
127
+
128
+ if injection != content
129
+ File.write(Rails.root.join(welcome_path), injection)
130
+ say_status :inject, "Added #{new_class} to welcome controller's invite_classes", :green
131
+ end
100
132
  end
101
133
 
102
134
  def create_views
103
135
  %w[landing show signup error].each do |view|
104
136
  template "packages/invites/app/views/invites/user_invitations/#{view}.html.erb",
105
- "packages/invites/app/views/invites/user_invitations/#{view}.html.erb"
137
+ "packages/invites/app/views/invites/#{invitations_path}/#{view}.html.erb"
106
138
  end
107
139
 
108
- template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
109
- "packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
140
+ # Welcome view is a one-shot too.
141
+ pending_path = "packages/invites/app/views/invites/welcome/pending_invitation.html.erb"
142
+ unless File.exist?(Rails.root.join(pending_path))
143
+ template "packages/invites/app/views/invites/welcome/pending_invitation.html.erb",
144
+ pending_path
145
+ end
110
146
 
111
- template "packages/invites/app/views/layouts/invites/invitation.html.erb",
112
- "packages/invites/app/views/layouts/invites/invitation.html.erb"
147
+ layout_path = "packages/invites/app/views/layouts/invites/invitation.html.erb"
148
+ unless File.exist?(Rails.root.join(layout_path))
149
+ template "packages/invites/app/views/layouts/invites/invitation.html.erb",
150
+ layout_path
151
+ end
113
152
  end
114
153
 
115
154
  def create_interactions
@@ -122,17 +161,17 @@ module Pu
122
161
 
123
162
  def create_definition
124
163
  template "packages/invites/app/definitions/invites/user_invite_definition.rb",
125
- "packages/invites/app/definitions/invites/user_invite_definition.rb"
164
+ "packages/invites/app/definitions/invites/#{invite_underscore}_definition.rb"
126
165
  end
127
166
 
128
167
  def create_policy
129
168
  template "packages/invites/app/policies/invites/user_invite_policy.rb",
130
- "packages/invites/app/policies/invites/user_invite_policy.rb"
169
+ "packages/invites/app/policies/invites/#{invite_underscore}_policy.rb"
131
170
  end
132
171
 
133
172
  def add_entity_association
134
173
  inject_into_file entity_model_path,
135
- " has_many :user_invites, class_name: \"Invites::UserInvite\", dependent: :destroy\n",
174
+ " has_many :#{invite_table}, class_name: \"Invites::#{invite_model}\", dependent: :destroy\n",
136
175
  before: /^\s*# add has_many associations above\.\n/
137
176
  end
138
177
 
@@ -203,31 +242,47 @@ module Pu
203
242
 
204
243
  def add_routes
205
244
  routes_content = File.read(Rails.root.join("config/routes.rb"))
206
- if routes_content.include?("# User invitation routes")
207
- say_status :skip, "Invitation routes already present", :yellow
208
- return
209
- end
245
+ flow_marker = "# Invitation routes for #{invite_model}"
246
+
247
+ if routes_content.include?(flow_marker)
248
+ say_status :skip, "Invitation routes for #{invite_model} already present", :yellow
249
+ else
250
+ welcome_present = routes_content.include?("# Invitation welcome routes")
210
251
 
211
- route_code = <<-RUBY
252
+ welcome_block = if welcome_present
253
+ ""
254
+ else
255
+ <<-RUBY
212
256
 
213
- # User invitation routes
257
+ # Invitation welcome routes (shared across all invite flows)
214
258
  scope module: :invites do
215
259
  get "invitations/welcome", to: "welcome#index", as: :invites_welcome_check
216
260
  delete "invitations/welcome", to: "welcome#skip", as: :invites_welcome_skip
217
- get "invitations/:token", to: "user_invitations#show", as: :invitation
218
- post "invitations/:token/accept", to: "user_invitations#accept", as: :accept_invitation
219
- get "invitations/:token/signup", to: "user_invitations#signup", as: :invitation_signup
220
- post "invitations/:token/signup", to: "user_invitations#signup"
221
261
  end
222
- RUBY
262
+ RUBY
263
+ end
264
+
265
+ flow_block = <<-RUBY
266
+
267
+ #{flow_marker}
268
+ scope module: :invites do
269
+ get "#{invitations_path}/:token", to: "#{invitations_path}#show", as: :#{invite_route_prefix}_invitation
270
+ post "#{invitations_path}/:token/accept", to: "#{invitations_path}#accept", as: :accept_#{invite_route_prefix}_invitation
271
+ get "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup", as: :#{invite_route_prefix}_invitation_signup
272
+ post "#{invitations_path}/:token/signup", to: "#{invitations_path}#signup"
273
+ end
274
+ RUBY
223
275
 
224
- inject_into_file "config/routes.rb",
225
- route_code,
226
- before: /^end\s*\z/
276
+ inject_into_file "config/routes.rb",
277
+ welcome_block + flow_block,
278
+ before: /^end\s*\z/
279
+ end
227
280
 
228
281
  # If no main WelcomeController exists, add /welcome route pointing to
229
282
  # Invites::WelcomeController so Rodauth's login_redirect "/welcome" works.
230
- unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb"))
283
+ routes_content = File.read(Rails.root.join("config/routes.rb"))
284
+ unless File.exist?(Rails.root.join("app/controllers/welcome_controller.rb")) ||
285
+ routes_content.include?(%(get "welcome", to: "invites/welcome#index"))
231
286
  welcome_route = <<-RUBY
232
287
 
233
288
  # Welcome route (handled by invites package — replace with pu:saas:welcome for full onboarding)
@@ -236,7 +291,7 @@ module Pu
236
291
 
237
292
  inject_into_file "config/routes.rb",
238
293
  welcome_route,
239
- before: /^\s*# User invitation routes/
294
+ before: /^\s*# Invitation welcome routes/
240
295
  end
241
296
  end
242
297
 
@@ -342,11 +397,23 @@ module Pu
342
397
  " return redirect_to(invites_welcome_check_path) if pending_invite\n\n",
343
398
  after: /def index\n/
344
399
 
345
- # Add invite_class method if not present
346
- unless file_content.include?("def invite_class")
400
+ # Add invite_classes method if neither it nor invite_class is present
401
+ if file_content !~ /def invite_classes\b/ && file_content !~ /def invite_class\b/
347
402
  inject_into_file relative_path,
348
- "\n def invite_class\n ::Invites::UserInvite\n end\n",
403
+ "\n def invite_classes\n [::Invites::#{invite_model}]\n end\n",
349
404
  before: /^end\s*\z/
405
+ else
406
+ # Inject this invite_model into the existing invite_classes array if missing.
407
+ host_content = File.read(Rails.root.join(relative_path))
408
+ if host_content =~ /def invite_classes\b/ && host_content !~ /::Invites::#{invite_model}\b/
409
+ updated = host_content.sub(/(\bdef invite_classes\b.*?\[)([^\]]*)(\])/m) do
410
+ before, list, after = Regexp.last_match[1], Regexp.last_match[2], Regexp.last_match[3]
411
+ existing = list.strip
412
+ new_list = existing.empty? ? "::Invites::#{invite_model}" : "#{existing.chomp(",").strip}, ::Invites::#{invite_model}"
413
+ "#{before}#{new_list}#{after}"
414
+ end
415
+ File.write(Rails.root.join(relative_path), updated)
416
+ end
350
417
  end
351
418
 
352
419
  # Update Invites::WelcomeController to redirect to /welcome (the main hub)
@@ -437,6 +504,40 @@ module Pu
437
504
  "app/policies/#{user_table}_policy.rb"
438
505
  end
439
506
 
507
+ def invite_model
508
+ return options[:invite_model].camelize if options[:invite_model].present?
509
+
510
+ # Flatten "::" so namespaced entities like Blogging::Post produce a
511
+ # valid (single-segment) class name: BloggingPostUserInvite.
512
+ entity_part = entity_model.delete(":")
513
+ user_part = user_model.delete(":")
514
+ "#{entity_part}#{user_part}Invite"
515
+ end
516
+
517
+ def invite_underscore
518
+ invite_model.underscore
519
+ end
520
+
521
+ def invite_table
522
+ invite_model.tableize
523
+ end
524
+
525
+ # e.g. UserInvite -> UserInvitationsController, FunderInvite -> FunderInvitationsController.
526
+ # If the input ends in "Invite", swap to "Invitations"; else append "Invitations".
527
+ def invitations_controller_class
528
+ base = invite_model.sub(/Invite\z/, "")
529
+ "#{base}InvitationsController"
530
+ end
531
+
532
+ def invitations_path
533
+ invitations_controller_class.sub(/Controller\z/, "").underscore
534
+ end
535
+
536
+ # Route helper prefix: "user" for UserInvite, "funder" for FunderInvite.
537
+ def invite_route_prefix
538
+ invite_model.sub(/Invite\z/, "").underscore.presence || "invite"
539
+ end
540
+
440
541
  def membership_model
441
542
  options[:membership_model] || "#{entity_model}#{user_model}"
442
543
  end
@@ -6,11 +6,17 @@ class <%= entity_model %>::InviteUserInteraction < Plutonium::Resource::Interact
6
6
  presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
7
 
8
8
  attribute :role
9
- input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
10
- <% if membership_model != "EntityUser" || user_model != "User" -%>
9
+ input :role, as: :select, choices: Invites::<%= invite_model %>.roles.keys.excluding("owner")
10
+ <% if membership_model != "EntityUser" || user_model != "User" || entity_association_name != "entity" -%>
11
11
 
12
12
  private
13
13
  <% end -%>
14
+ <% if entity_association_name != "entity" -%>
15
+
16
+ def invite_entity_attribute
17
+ :<%= entity_association_name %>
18
+ end
19
+ <% end -%>
14
20
  <% if membership_model != "EntityUser" -%>
15
21
 
16
22
  def membership_class
@@ -6,13 +6,19 @@ class <%= user_model %>::InviteUserInteraction < Plutonium::Resource::Interactio
6
6
  presents label: "Invite <%= user_model.underscore.humanize.titleize %>", icon: Phlex::TablerIcons::Mail
7
7
 
8
8
  attribute :role
9
- input :role, as: :select, choices: Invites::UserInvite.roles.keys.excluding("owner")
9
+ input :role, as: :select, choices: Invites::<%= invite_model %>.roles.keys.excluding("owner")
10
10
 
11
11
  private
12
12
 
13
13
  def entity
14
14
  current_scoped_entity
15
15
  end
16
+ <% if entity_association_name != "entity" -%>
17
+
18
+ def invite_entity_attribute
19
+ :<%= entity_association_name %>
20
+ end
21
+ <% end -%>
16
22
  <% if membership_model != "EntityUser" -%>
17
23
 
18
24
  def membership_class
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
3
+ class Create<%= invite_table.camelize %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
- create_table :user_invites do |t|
5
+ create_table :<%= invite_table %> do |t|
6
6
  # Entity association
7
7
  t.belongs_to :<%= entity_association_name %>, null: false, foreign_key: true
8
8
 
@@ -35,13 +35,13 @@ class CreateUserInvites < ActiveRecord::Migration[<%= ActiveRecord::Migration.cu
35
35
 
36
36
  # Only one pending invite per email per entity
37
37
  t.index [:<%= entity_association_name %>_id, :email], unique: true, where: "state = 0",
38
- name: "index_user_invites_on_entity_email_pending"
38
+ name: "index_<%= invite_table %>_on_entity_email_pending"
39
39
 
40
40
  # Only one pending invite per invitable (when invitable is present)
41
41
  t.index [:invitable_type, :invitable_id],
42
42
  unique: true,
43
43
  where: "state = 0 AND invitable_id IS NOT NULL",
44
- name: "index_user_invites_on_invitable_pending"
44
+ name: "index_<%= invite_table %>_on_invitable_pending"
45
45
  end
46
46
  end
47
47
  end
@@ -1,20 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class UserInvitationsController < ApplicationController
4
+ class <%= invitations_controller_class %> < ApplicationController
5
5
  <% if rodauth? -%>
6
6
  include Plutonium::Auth::Rodauth(:<%= rodauth_config %>)
7
7
  <% end -%>
8
8
  include Plutonium::Invites::Controller
9
9
 
10
- prepend_view_path Invites::Engine.root.join("app/views")
11
10
  layout "invites/invitation"
12
11
  helper_method :login_path
13
12
 
14
13
  private
15
14
 
16
15
  def invite_class
17
- ::Invites::UserInvite
16
+ ::Invites::<%= invite_model %>
17
+ end
18
+
19
+ def invitation_path_for(token)
20
+ <%= invite_route_prefix %>_invitation_path(token: token)
18
21
  end
19
22
 
20
23
  def user_class
@@ -68,7 +71,9 @@ module Invites
68
71
 
69
72
  def sign_in_user(user)
70
73
  rodauth.account_from_login(user.email)
71
- rodauth.login("signup")
74
+ # login_session just persists the session; `login` would redirect to
75
+ # rodauth.login_redirect and short-circuit our post-accept redirect.
76
+ rodauth.login_session("signup")
72
77
  end
73
78
  <% else -%>
74
79
  def create_user_for_signup(email, password)
@@ -32,8 +32,8 @@ module Invites
32
32
 
33
33
  private
34
34
 
35
- def invite_class
36
- ::Invites::UserInvite
35
+ def invite_classes
36
+ [::Invites::<%= invite_model %>]
37
37
  end
38
38
 
39
39
  # Returns the path to redirect to after the welcome flow completes.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class UserInviteDefinition < Invites::ResourceDefinition
4
+ class <%= invite_model %>Definition < Invites::ResourceDefinition
5
5
  action :resend, interaction: Invites::ResendInviteInteraction, collection_record_action: false
6
6
  action :cancel, interaction: Invites::CancelInviteInteraction, collection_record_action: false
7
7