plutonium 0.49.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-invites/SKILL.md +41 -0
  3. data/CHANGELOG.md +15 -0
  4. data/app/assets/plutonium.js +35 -0
  5. data/app/assets/plutonium.js.map +3 -3
  6. data/app/assets/plutonium.min.js +24 -24
  7. data/app/assets/plutonium.min.js.map +3 -3
  8. data/app/views/plutonium/_flash.html.erb +1 -1
  9. data/docs/guides/user-invites.md +64 -0
  10. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md +1487 -0
  11. data/docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json +15 -0
  12. data/gemfiles/rails_7.gemfile.lock +1 -1
  13. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  14. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  15. data/lib/generators/pu/invites/install_generator.rb +136 -35
  16. data/lib/generators/pu/invites/templates/app/interactions/invite_user_interaction.rb.tt +8 -2
  17. data/lib/generators/pu/invites/templates/app/interactions/user_invite_user_interaction.rb.tt +7 -1
  18. data/lib/generators/pu/invites/templates/db/migrate/create_user_invites.rb.tt +4 -4
  19. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +9 -4
  20. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/welcome_controller.rb.tt +2 -2
  21. data/lib/generators/pu/invites/templates/packages/invites/app/definitions/invites/user_invite_definition.rb.tt +1 -1
  22. data/lib/generators/pu/invites/templates/packages/invites/app/mailers/invites/user_invite_mailer.rb.tt +8 -8
  23. data/lib/generators/pu/invites/templates/packages/invites/app/models/invites/user_invite.rb.tt +13 -4
  24. data/lib/generators/pu/invites/templates/packages/invites/app/policies/invites/user_invite_policy.rb.tt +3 -3
  25. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/landing.html.erb.tt +1 -1
  26. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/show.html.erb.tt +1 -1
  27. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invitations/signup.html.erb.tt +2 -2
  28. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb.tt +4 -4
  29. data/lib/generators/pu/invites/templates/packages/invites/app/views/invites/user_invite_mailer/invitation.text.erb.tt +4 -4
  30. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +5 -1
  31. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +5 -1
  32. data/lib/plutonium/invites/concerns/invite_token.rb +11 -3
  33. data/lib/plutonium/invites/concerns/invite_user.rb +13 -4
  34. data/lib/plutonium/invites/controller.rb +14 -1
  35. data/lib/plutonium/invites/pending_invite_check.rb +37 -28
  36. data/lib/plutonium/resource/controllers/interactive_actions.rb +13 -9
  37. data/lib/plutonium/resource/policy.rb +23 -8
  38. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  39. data/lib/plutonium/version.rb +1 -1
  40. data/package.json +1 -1
  41. data/src/js/controllers/flatpickr_controller.js +23 -0
  42. data/src/js/controllers/sidebar_controller.js +28 -1
  43. metadata +5 -3
@@ -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.48.0)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.48.0)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.48.0)
4
+ plutonium (0.49.0)
5
5
  action_policy (~> 0.7.0)
6
6
  listen (~> 3.8)
7
7
  pagy (~> 43.0)
@@ -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
 
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class UserInviteMailer < ApplicationMailer
4
+ class <%= invite_model %>Mailer < ApplicationMailer
5
5
  prepend_view_path Invites::Engine.root.join("app/views")
6
6
 
7
- def invitation(user_invite)
8
- @user_invite = user_invite
9
- @invitation_url = invitation_url(token: user_invite.token)
7
+ def invitation(invite)
8
+ @invite = invite
9
+ @invitation_url = <%= invite_route_prefix %>_invitation_url(token: invite.token)
10
10
 
11
11
  mail(
12
- to: user_invite.email,
12
+ to: invite.email,
13
13
  subject: invitation_subject,
14
14
  template_name: invitation_template_name
15
15
  )
@@ -18,14 +18,14 @@ module Invites
18
18
  private
19
19
 
20
20
  def invitation_subject
21
- "You've been invited to join #{@user_invite.entity.to_label}"
21
+ "You've been invited to join #{@invite.entity.to_label}"
22
22
  end
23
23
 
24
24
  def invitation_template_name
25
- return "invitation" unless @user_invite.invitable_type.present?
25
+ return "invitation" unless @invite.invitable_type.present?
26
26
 
27
27
  # e.g., "TenantProfile" -> "invitation_tenant_profile"
28
- template = "invitation_#{@user_invite.invitable_type.underscore}"
28
+ template = "invitation_#{@invite.invitable_type.underscore}"
29
29
  template_exists?(template) ? template : "invitation"
30
30
  end
31
31
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class UserInvite < Invites::ResourceRecord
4
+ class <%= invite_model %> < Invites::ResourceRecord
5
5
  include Plutonium::Invites::Concerns::InviteToken
6
6
 
7
7
  enum :role, <%= membership_model %>.roles
@@ -18,7 +18,7 @@ module Invites
18
18
  # Implement required methods from InviteToken concern
19
19
 
20
20
  def invitation_mailer
21
- Invites::UserInviteMailer
21
+ Invites::<%= invite_model %>Mailer
22
22
  end
23
23
 
24
24
  def enforce_domain
@@ -30,11 +30,20 @@ module Invites
30
30
  end
31
31
 
32
32
  def create_membership_for(user)
33
- <%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, user: user, role: role)
33
+ <%= membership_model %>.create!(<%= entity_association_name %>: <%= entity_association_name %>, <%= user_association_name %>: user, role: role)
34
34
  end
35
+ <% if user_table != "user" -%>
36
+
37
+ # InviteToken concern updates `user_attribute => user` when accepting.
38
+ # Our invite model uses `belongs_to :<%= user_table %>` instead.
39
+ def user_attribute
40
+ :<%= user_table %>
41
+ end
42
+ <% end -%>
35
43
  <% if entity_association_name != "entity" -%>
36
44
 
37
- # Alias for InviteToken concern compatibility
45
+ # Alias for InviteToken concern compatibility (used by views/mailers
46
+ # that read `@invite.entity.to_label`).
38
47
  alias_method :entity, :<%= entity_association_name %>
39
48
  <% end -%>
40
49
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Invites
4
- class UserInvitePolicy < Invites::ResourcePolicy
4
+ class <%= invite_model %>Policy < Invites::ResourcePolicy
5
5
  def create?
6
6
  false # Created via InviteUserInteraction
7
7
  end
@@ -19,11 +19,11 @@ module Invites
19
19
  end
20
20
 
21
21
  def permitted_attributes_for_create
22
- [:entity, :email, :role, :invited_by, :expires_at]
22
+ [:<%= entity_association_name %>, :email, :role, :invited_by, :expires_at]
23
23
  end
24
24
 
25
25
  def permitted_attributes_for_read
26
- [:entity, :email, :role, :state, :invited_by, :expires_at, :accepted_at, :<%= user_table %>]
26
+ [:<%= entity_association_name %>, :email, :role, :state, :invited_by, :expires_at, :accepted_at, :<%= user_table %>]
27
27
  end
28
28
 
29
29
  def permitted_associations
@@ -22,7 +22,7 @@
22
22
  <p class="text-[var(--pu-text-muted)] mb-4">To accept this invitation, please:</p>
23
23
  </div>
24
24
 
25
- <%%= link_to "Create Account", invitation_signup_path(token: params[:token]),
25
+ <%%= link_to "Create Account", <%= invite_route_prefix %>_invitation_signup_path(token: params[:token]),
26
26
  class: "w-full block pu-btn pu-btn-md pu-btn-primary text-center" %>
27
27
 
28
28
  <div class="text-center">
@@ -29,7 +29,7 @@
29
29
  <%% end %>
30
30
 
31
31
  <div class="space-y-3 pt-4">
32
- <%%= form_with url: accept_invitation_path(token: params[:token]), method: :post, local: true do |form| %>
32
+ <%%= form_with url: accept_<%= invite_route_prefix %>_invitation_path(token: params[:token]), method: :post, local: true do |form| %>
33
33
  <%%= form.submit "Accept Invitation",
34
34
  class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
35
35
  <%% end %>
@@ -10,7 +10,7 @@
10
10
  Create Your Account
11
11
  </h1>
12
12
 
13
- <%%= form_with url: invitation_signup_path(token: @invite.token), method: :post, local: true, class: "space-y-4", data: { turbo: false } do |form| %>
13
+ <%%= form_with url: <%= invite_route_prefix %>_invitation_signup_path(token: @invite.token), method: :post, local: true, class: "space-y-4", data: { turbo: false } do |form| %>
14
14
  <div>
15
15
  <label for="email" class="pu-label">Email</label>
16
16
  <%% if @invite.enforce_email? %>
@@ -43,7 +43,7 @@
43
43
  <div class="space-y-3 pt-4">
44
44
  <%%= form.submit "Create Account",
45
45
  class: "w-full pu-btn pu-btn-md pu-btn-primary cursor-pointer" %>
46
- <%%= link_to "Back", invitation_path(token: @invite.token),
46
+ <%%= link_to "Back", <%= invite_route_prefix %>_invitation_path(token: @invite.token),
47
47
  class: "w-full block pu-btn pu-btn-md pu-btn-outline text-center" %>
48
48
  </div>
49
49
  <%% end %>
@@ -22,11 +22,11 @@
22
22
  </head>
23
23
  <body>
24
24
  <div class="email-container">
25
- <h2>You've been invited to <%%= @user_invite.entity.to_label %></h2>
25
+ <h2>You've been invited to <%%= @invite.entity.to_label %></h2>
26
26
 
27
27
  <p>Hi there,</p>
28
28
 
29
- <p><%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%%= @user_invite.role %>.</p>
29
+ <p><%%= @invite.invited_by.email %> has invited you to join <%%= @invite.entity.to_label %> as a <%%= @invite.role %>.</p>
30
30
 
31
31
  <p>Click the link below to accept your invitation:</p>
32
32
 
@@ -35,8 +35,8 @@
35
35
  <p>Or copy and paste this URL into your browser:</p>
36
36
  <p><%%= @invitation_url %></p>
37
37
 
38
- <%% if @user_invite.expires_at.present? %>
39
- <p><small>This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %></small></p>
38
+ <%% if @invite.expires_at.present? %>
39
+ <p><small>This invitation will expire on <%%= @invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %></small></p>
40
40
  <%% end %>
41
41
 
42
42
  <p>If you didn't expect this invitation, you can safely ignore this email.</p>
@@ -1,15 +1,15 @@
1
- You've been invited to <%%= @user_invite.entity.to_label %>
1
+ You've been invited to <%%= @invite.entity.to_label %>
2
2
  ======================================================
3
3
 
4
4
  Hi there,
5
5
 
6
- <%%= @user_invite.invited_by.email %> has invited you to join <%%= @user_invite.entity.to_label %> as a <%%= @user_invite.role %>.
6
+ <%%= @invite.invited_by.email %> has invited you to join <%%= @invite.entity.to_label %> as a <%%= @invite.role %>.
7
7
 
8
8
  Accept your invitation by visiting this link:
9
9
  <%%= @invitation_url %>
10
10
 
11
- <%% if @user_invite.expires_at.present? %>
12
- This invitation will expire on <%%= @user_invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
11
+ <%% if @invite.expires_at.present? %>
12
+ This invitation will expire on <%%= @invite.expires_at.strftime("%B %d, %Y at %l:%M %p") %>
13
13
  <%% end %>
14
14
 
15
15
  If you didn't expect this invitation, you can safely ignore this email.
@@ -7,7 +7,11 @@
7
7
  <meta name="csrf-param" content="authenticity_token" />
8
8
  <meta name="csrf-token" content="<%%= form_authenticity_token %>" />
9
9
  <%%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
10
- <%%= javascript_importmap_tags %>
10
+ <%% if defined?(Importmap) %>
11
+ <%%= javascript_importmap_tags %>
12
+ <%% else %>
13
+ <%%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
14
+ <%% end %>
11
15
  </head>
12
16
  <body class="antialiased min-h-screen bg-[var(--pu-body)]">
13
17
  <main class="p-4 min-h-screen flex flex-col items-center justify-center gap-2 px-6 py-8 mx-auto lg:py-0">