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
@@ -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">
@@ -55,7 +55,7 @@ module PlutoniumGenerators
55
55
  end.compact!
56
56
  end
57
57
 
58
- def new_database(name, migrations_paths: nil)
58
+ def new_database(name, migrations_paths: nil, schema_dump: nil)
59
59
  migrations_paths ||= "db/#{name}_migrate"
60
60
  db = Psych::Nodes::Mapping.new(name)
61
61
  db.children.concat [
@@ -66,6 +66,12 @@ module PlutoniumGenerators
66
66
  Psych::Nodes::Scalar.new("database"),
67
67
  Psych::Nodes::Scalar.new("storage/<%= Rails.env %>-#{name}.sqlite3")
68
68
  ]
69
+ unless schema_dump.nil?
70
+ db.children.concat [
71
+ Psych::Nodes::Scalar.new("schema_dump"),
72
+ Psych::Nodes::Scalar.new(schema_dump.to_s)
73
+ ]
74
+ end
69
75
  "\n" + emit_pair(Psych::Nodes::Scalar.new(name), db)
70
76
  end
71
77
 
@@ -91,10 +97,10 @@ module PlutoniumGenerators
91
97
  @database_yaml ||= DatabaseYAML.new(path: File.expand_path("config/database.yml", destination_root))
92
98
  end
93
99
 
94
- def add_sqlite_database(name, migrations_paths: nil)
100
+ def add_sqlite_database(name, migrations_paths: nil, schema_dump: nil)
95
101
  # Define the new database configuration
96
102
  insert_into_file "config/database.yml",
97
- database_yaml.new_database(name, migrations_paths: migrations_paths) + "\n",
103
+ database_yaml.new_database(name, migrations_paths: migrations_paths, schema_dump: schema_dump) + "\n",
98
104
  after: database_yaml.database_def_regex("default"),
99
105
  verbose: false,
100
106
  force: false
@@ -51,10 +51,13 @@ module Pu
51
51
  end
52
52
 
53
53
  # Then add database config
54
- add_sqlite_database(@db_name, migrations_paths: "db/rails_pulse_migrate")
54
+ add_sqlite_database(@db_name, migrations_paths: "db/rails_pulse_migrate", schema_dump: false)
55
55
 
56
- # Finally prepare the database (runs migration that loads schema)
57
- prepare_database(@db_name)
56
+ # rails_pulse ships a callable schema lambda; load it idempotently
57
+ # against the rails_pulse connection.
58
+ Bundler.with_unbundled_env do
59
+ run "bin/rails db:schema:load_rails_pulse"
60
+ end
58
61
  end
59
62
 
60
63
  def mount_rails_pulse_engine
@@ -6,6 +6,24 @@ RailsPulse.configure do |config|
6
6
 
7
7
  # Asset tracking (disable to reduce noise)
8
8
  config.track_assets = false
9
+
10
+ # Background job tracking (off by default in the gem; enabling mounts /jobs)
11
+ config.track_jobs = true
12
+
13
+ # Don't capture job arguments — they may contain sensitive data
14
+ config.capture_job_arguments = false
15
+
16
+ # Match the engine mount so Pulse doesn't self-track its own dashboard
17
+ config.mount_path = "<%= options[:route] %>"
18
+
19
+ # Auth is handled upstream by ManagementConstraint in routes.rb;
20
+ # disable the gem's built-in auth to avoid double prompts.
21
+ config.authentication_enabled = false
22
+
23
+ # Skip Rails' default health check and other mounted management engines
24
+ # (mission_control-jobs, solid_errors, litestream, etc.) so they don't
25
+ # pollute the application route list.
26
+ config.ignored_routes = ["/up", "/cable", %r{^/manage/}]
9
27
  <%- if options[:database] -%>
10
28
 
11
29
  # Use separate database for performance data
@@ -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">
@@ -18,7 +18,14 @@ module Plutonium
18
18
  raise exception
19
19
  end
20
20
  format.any do
21
- @errors = ActiveModel::Errors.new(exception.policy.record)
21
+ # ActionPolicy stores the policy *class* on the exception
22
+ # (see ActionPolicy::Unauthorized#initialize), so reach for the
23
+ # live policy instance instead. Its record may itself be a Class
24
+ # for collection actions where no record is loaded — instantiate
25
+ # so ActiveModel::Errors has a real model instance to work with.
26
+ record = current_policy.record
27
+ record = record.new if record.is_a?(Class)
28
+ @errors = ActiveModel::Errors.new(record)
22
29
  @errors.add(:base, :unauthorized, message: exception.result.message)
23
30
  render "errors", status: :forbidden
24
31
  end
@@ -248,8 +255,8 @@ module Plutonium
248
255
  end
249
256
 
250
257
  # Build named route helper, mirroring the pattern used by build_nested_resource_url_args.
251
- # e.g., "organization_scope_widgets" (collection), "organization_scope_widget" (member),
252
- # "edit_organization_scope_widget" (edit action)
258
+ # e.g., "organization_scoped_widgets" (collection), "organization_scoped_widget" (member),
259
+ # "edit_organization_scoped_widget" (edit action)
253
260
  is_collection_action = action == :index || action == :create || (no_record && action != :new)
254
261
  helper_base = if is_singular || is_collection_action
255
262
  model_class.model_name.plural
@@ -19,7 +19,7 @@ module Plutonium
19
19
  # time — they're stable strings and don't depend on the live class
20
20
  # identity, so caching them is safe.
21
21
  resolved = @scoped_entity_class_name.constantize
22
- @scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}_scope"
22
+ @scoped_entity_param_key = param_key || :"#{resolved.model_name.singular_route_key}_scoped"
23
23
  @scoped_entity_route_key = route_key || resolved.model_name.singular.to_sym
24
24
  end
25
25
 
@@ -125,9 +125,9 @@ module Plutonium
125
125
 
126
126
  transaction do
127
127
  update!(
128
- state: :accepted,
129
- accepted_at: Time.current,
130
- user: user
128
+ :state => :accepted,
129
+ :accepted_at => Time.current,
130
+ user_attribute => user
131
131
  )
132
132
 
133
133
  create_membership_for(user)
@@ -135,6 +135,14 @@ module Plutonium
135
135
  end
136
136
  end
137
137
 
138
+ # Override to specify the user association name on the invite model.
139
+ # Defaults to :user. Override when the invite model's `belongs_to`
140
+ # uses a different name (e.g., :spender_account, :staff_user).
141
+ # @return [Symbol]
142
+ def user_attribute
143
+ :user
144
+ end
145
+
138
146
  # Override this method to specify the mailer class.
139
147
  #
140
148
  # @return [Class] the mailer class for sending invitation emails
@@ -37,12 +37,12 @@ module Plutonium
37
37
 
38
38
  def execute
39
39
  attrs = {
40
- entity: entity,
41
40
  email: email,
42
41
  role: role,
43
42
  invited_by: current_user,
44
43
  **additional_invite_attributes
45
44
  }
45
+ attrs[invite_entity_attribute] = entity
46
46
  attrs[:invitable] = invitable if invitable.present?
47
47
 
48
48
  invite_class.create!(attrs)
@@ -109,6 +109,15 @@ module Plutonium
109
109
  entity.class.name.underscore.to_sym
110
110
  end
111
111
 
112
+ # Override to specify the entity association name on the invite model.
113
+ # Defaults to :entity, matching the convention documented on InviteToken.
114
+ # When the invite model uses a concrete `belongs_to :<entity_name>`
115
+ # instead, override this to return that association name.
116
+ # @return [Symbol]
117
+ def invite_entity_attribute
118
+ :entity
119
+ end
120
+
112
121
  def role_is_present
113
122
  errors.add(:role, :blank) if role.blank?
114
123
  end
@@ -130,9 +139,9 @@ module Plutonium
130
139
  return unless email.present? && entity.present?
131
140
 
132
141
  pending = invite_class.find_by(
133
- entity: entity,
134
- email: email,
135
- state: :pending
142
+ invite_entity_attribute => entity,
143
+ :email => email,
144
+ :state => :pending
136
145
  )
137
146
  errors.add(:email, "already has a pending invitation") if pending
138
147
  end
@@ -35,6 +35,7 @@ module Plutonium
35
35
  extend ActiveSupport::Concern
36
36
 
37
37
  included do
38
+ append_view_path File.expand_path("app/views", Plutonium.root)
38
39
  helper_method :current_user if respond_to?(:helper_method)
39
40
  end
40
41
 
@@ -72,7 +73,7 @@ module Plutonium
72
73
  return unless (@invite = load_and_validate_invite(params[:token]))
73
74
 
74
75
  unless current_user
75
- redirect_to invitation_path(token: params[:token]),
76
+ redirect_to invitation_path_for(params[:token]),
76
77
  alert: "Please sign in to accept this invitation"
77
78
  return
78
79
  end
@@ -174,6 +175,18 @@ module Plutonium
174
175
  raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
175
176
  end
176
177
 
178
+ # Override to customize the invitation URL helper.
179
+ # Default uses Rails' `invitation_path(token:)` helper, which is what
180
+ # `pu:invites:install` generates for single-flow apps. Multi-flow apps
181
+ # whose generator scoped the route as `<prefix>_invitation_path` should
182
+ # override this.
183
+ #
184
+ # @param token [String] the invitation token
185
+ # @return [String] the URL path
186
+ def invitation_path_for(token)
187
+ invitation_path(token: token)
188
+ end
189
+
177
190
  # Override to specify the user model class.
178
191
  #
179
192
  # @return [Class] the user model class
@@ -8,39 +8,34 @@ module Plutonium
8
8
  # (e.g., WelcomeController, DashboardController) to check for
9
9
  # pending invitations stored in cookies.
10
10
  #
11
- # @example Basic usage
12
- # class WelcomeController < ApplicationController
13
- # include Plutonium::Invites::PendingInviteCheck
11
+ # Hosts may override either `invite_classes` (preferred — returns
12
+ # an Array of invite classes to check, in priority order) or
13
+ # `invite_class` (single class, kept for backward compatibility).
14
14
  #
15
- # def index
16
- # return if redirect_to_pending_invite!
17
- #
18
- # # Normal post-login flow...
19
- # redirect_to dashboard_path
20
- # end
21
- #
22
- # private
23
- #
24
- # def invite_class
25
- # Invites::UserInvite
26
- # end
15
+ # @example Single invite class
16
+ # def invite_class
17
+ # ::Invites::UserInvite
27
18
  # end
28
19
  #
20
+ # @example Multiple invite classes
21
+ # def invite_classes
22
+ # [::Invites::FunderInvite, ::Invites::SpenderInvite]
23
+ # end
29
24
  module PendingInviteCheck
30
25
  extend ActiveSupport::Concern
31
26
 
27
+ included do
28
+ append_view_path File.expand_path("app/views", Plutonium.root)
29
+ end
30
+
32
31
  private
33
32
 
34
33
  # Check for a pending invitation and redirect if found.
35
- #
36
- # @return [Boolean] true if redirected, false otherwise
37
34
  def redirect_to_pending_invite!
38
35
  token = cookies.encrypted[:pending_invitation]
39
36
  return false unless token
40
37
 
41
- invite = invite_class.find_for_acceptance(token)
42
-
43
- if invite
38
+ if find_pending_invite(token)
44
39
  redirect_to invitation_path(token: token)
45
40
  true
46
41
  else
@@ -49,14 +44,12 @@ module Plutonium
49
44
  end
50
45
  end
51
46
 
52
- # Returns the pending invite if one exists.
53
- #
54
- # @return [Object, nil] the pending invite or nil
47
+ # Returns the pending invite if one exists across any invite_classes.
55
48
  def pending_invite
56
49
  token = cookies.encrypted[:pending_invitation]
57
50
  return nil unless token
58
51
 
59
- invite = invite_class.find_for_acceptance(token)
52
+ invite = find_pending_invite(token)
60
53
  unless invite
61
54
  cookies.delete(:pending_invitation)
62
55
  return nil
@@ -65,11 +58,27 @@ module Plutonium
65
58
  invite
66
59
  end
67
60
 
68
- # Override to specify the invite model class.
69
- #
70
- # @return [Class] the invite model class
61
+ # Override to specify multiple invite model classes (preferred).
62
+ # Defaults to `[invite_class]` for backward compatibility.
63
+ # @return [Array<Class>]
64
+ def invite_classes
65
+ [invite_class]
66
+ end
67
+
68
+ # Override to specify a single invite model class. Maintained for
69
+ # backward compatibility; prefer `invite_classes` for multi-flow apps.
70
+ # @return [Class]
71
71
  def invite_class
72
- raise NotImplementedError, "#{self.class}#invite_class must return the invite model class"
72
+ raise NotImplementedError,
73
+ "#{self.class}#invite_class or #invite_classes must return the invite model class(es)"
74
+ end
75
+
76
+ def find_pending_invite(token)
77
+ invite_classes.each do |klass|
78
+ invite = klass.find_for_acceptance(token)
79
+ return invite if invite
80
+ end
81
+ nil
73
82
  end
74
83
  end
75
84
  end
@@ -29,7 +29,7 @@ module Plutonium
29
29
  # GET /resources/1/record_actions/:interactive_action
30
30
  def interactive_record_action
31
31
  build_interactive_record_action_interaction
32
- render :interactive_record_action, layout: modal_layout, formats: [:html]
32
+ render :interactive_record_action, formats: [:html], **modal_render_options
33
33
  end
34
34
 
35
35
  # POST /resources/1/record_actions/:interactive_action
@@ -62,7 +62,7 @@ module Plutonium
62
62
  end
63
63
  else
64
64
  format.any(:html, :turbo_stream) do
65
- render :interactive_record_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
65
+ render :interactive_record_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
66
66
  end
67
67
  format.any do
68
68
  @errors = @interaction.errors
@@ -77,7 +77,7 @@ module Plutonium
77
77
  def interactive_resource_action
78
78
  skip_verify_current_authorized_scope!
79
79
  build_interactive_resource_action_interaction
80
- render :interactive_resource_action, layout: modal_layout, formats: [:html]
80
+ render :interactive_resource_action, formats: [:html], **modal_render_options
81
81
  end
82
82
 
83
83
  # POST /resources/resource_actions/:interactive_action
@@ -111,7 +111,7 @@ module Plutonium
111
111
  end
112
112
  else
113
113
  format.any(:html, :turbo_stream) do
114
- render :interactive_resource_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
114
+ render :interactive_resource_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
115
115
  end
116
116
  format.any do
117
117
  @errors = @interaction.errors
@@ -125,7 +125,7 @@ module Plutonium
125
125
  # GET /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
126
126
  def interactive_bulk_action
127
127
  build_interactive_bulk_action_interaction
128
- render :interactive_bulk_action, layout: modal_layout, formats: [:html]
128
+ render :interactive_bulk_action, formats: [:html], **modal_render_options
129
129
  end
130
130
 
131
131
  # POST /resources/bulk_actions/:interactive_action?ids[]=1&ids[]=2
@@ -158,7 +158,7 @@ module Plutonium
158
158
  end
159
159
  else
160
160
  format.any(:html, :turbo_stream) do
161
- render :interactive_bulk_action, layout: modal_layout, formats: [:html], status: :unprocessable_content
161
+ render :interactive_bulk_action, formats: [:html], content_type: "text/html", **modal_render_options, status: :unprocessable_content
162
162
  end
163
163
  format.any do
164
164
  @errors = @interaction.errors
@@ -171,9 +171,13 @@ module Plutonium
171
171
 
172
172
  private
173
173
 
174
- # Returns false for modal requests (skip layout), nil otherwise (use default layout)
175
- def modal_layout
176
- helpers.current_turbo_frame.present? ? false : nil
174
+ # Render options for modal-aware actions. Returns `{ layout: false }` for
175
+ # turbo-frame requests so the bare frame is rendered, and an empty hash
176
+ # for top-level requests so the controller's default layout proc applies.
177
+ # (Passing `layout: nil` explicitly is treated as "no layout" by Rails,
178
+ # which is why we omit the key entirely on the default path.)
179
+ def modal_render_options
180
+ helpers.current_turbo_frame.present? ? {layout: false} : {}
177
181
  end
178
182
 
179
183
  def current_interactive_action