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
@@ -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">
@@ -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
@@ -67,16 +67,31 @@ module Plutonium
67
67
  raise ArgumentError, "parent and parent_association must both be provided together"
68
68
  end
69
69
 
70
- # Parent association scoping (nested routes)
71
- # The parent was already entity-scoped during authorization, so children
72
- # accessed through the parent don't need additional entity scoping
70
+ # Parent association scoping (nested routes).
71
+ #
72
+ # The parent context is set on the policy for the whole request, so it
73
+ # leaks into sibling lookups too — e.g. a SecureAssociation field on
74
+ # the child's form authorizes an unrelated resource scope while
75
+ # parent/parent_association are still set. Only apply parent scoping
76
+ # when the relation actually corresponds to the parent's named
77
+ # association; otherwise fall through to entity scoping so we don't
78
+ # produce an incoherent (and silently empty) result.
73
79
  assoc_reflection = parent.class.reflect_on_association(parent_association)
74
- if assoc_reflection.collection?
75
- # has_many: merge with the association's scope
76
- parent.public_send(parent_association).merge(relation)
80
+ if assoc_reflection && relation.klass <= assoc_reflection.klass
81
+ # The parent was already entity-scoped during authorization, so
82
+ # children accessed through the parent don't need additional
83
+ # entity scoping.
84
+ if assoc_reflection.collection?
85
+ # has_many: merge with the association's scope
86
+ parent.public_send(parent_association).merge(relation)
87
+ else
88
+ # has_one: scope by foreign key
89
+ relation.where(assoc_reflection.foreign_key => parent.id)
90
+ end
91
+ elsif entity_scope
92
+ relation.associated_with(entity_scope)
77
93
  else
78
- # has_one: scope by foreign key
79
- relation.where(assoc_reflection.foreign_key => parent.id)
94
+ relation
80
95
  end
81
96
  elsif entity_scope
82
97
  # Entity scoping (multi-tenancy)
@@ -35,7 +35,7 @@ module Plutonium
35
35
  def render_content(&)
36
36
  div(
37
37
  id: "sidebar-navigation-content",
38
- data: {turbo_permanent: true},
38
+ data: {turbo_permanent: true, sidebar_target: "scroll"},
39
39
  class: "overflow-y-auto py-5 px-3 h-full bg-[var(--pu-surface)] border-r border-[var(--pu-border)]",
40
40
  &
41
41
  )
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.49.0"
2
+ VERSION = "0.49.1"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
data/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radioactive-labs/plutonium",
3
- "version": "0.49.0",
3
+ "version": "0.49.1",
4
4
  "description": "Build production-ready Rails apps in minutes, not days. Convention-driven, fully customizable, AI-ready.",
5
5
  "type": "module",
6
6
  "main": "src/js/core.js",
@@ -54,7 +54,30 @@ export default class extends Controller {
54
54
  }
55
55
 
56
56
  if (this.modal) {
57
+ // Inside a <dialog> opened via showModal(), the dialog establishes its
58
+ // own containing block in the top layer. flatpickr's default positioning
59
+ // computes document coordinates but the calendar (appended to the
60
+ // dialog) interprets them relative to the dialog's box, placing the
61
+ // calendar far from the input. Append to the modal and reposition
62
+ // manually relative to the modal's bounding rect.
57
63
  options.appendTo = this.modal;
64
+ options.position = (instance) => {
65
+ const input = instance.altInput || instance.input;
66
+ const inputRect = input.getBoundingClientRect();
67
+ const modalRect = this.modal.getBoundingClientRect();
68
+ const cal = instance.calendarContainer;
69
+ const calHeight = cal.offsetHeight;
70
+ const spaceBelow = window.innerHeight - inputRect.bottom;
71
+ const showAbove = spaceBelow < calHeight && inputRect.top > calHeight;
72
+ const top = showAbove
73
+ ? inputRect.top - modalRect.top - calHeight - 2
74
+ : inputRect.bottom - modalRect.top + 2;
75
+ cal.style.top = `${top}px`;
76
+ cal.style.left = `${inputRect.left - modalRect.left}px`;
77
+ cal.style.right = "auto";
78
+ cal.classList.toggle("arrowTop", !showAbove);
79
+ cal.classList.toggle("arrowBottom", showAbove);
80
+ };
58
81
  }
59
82
 
60
83
  return options;
@@ -1,3 +1,30 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
- export default class extends Controller {}
3
+ // Persists across controller reconnects so the value saved on
4
+ // turbo:before-render is still available on turbo:render, even though
5
+ // the <aside> hosting this controller is replaced during navigation.
6
+ let savedScrollTop = 0;
7
+
8
+ export default class extends Controller {
9
+ static targets = ["scroll"];
10
+
11
+ connect() {
12
+ this.beforeRender = this.beforeRender.bind(this);
13
+ this.afterRender = this.afterRender.bind(this);
14
+ document.addEventListener("turbo:before-render", this.beforeRender);
15
+ document.addEventListener("turbo:render", this.afterRender);
16
+ }
17
+
18
+ disconnect() {
19
+ document.removeEventListener("turbo:before-render", this.beforeRender);
20
+ document.removeEventListener("turbo:render", this.afterRender);
21
+ }
22
+
23
+ beforeRender() {
24
+ if (this.hasScrollTarget) savedScrollTop = this.scrollTarget.scrollTop;
25
+ }
26
+
27
+ afterRender() {
28
+ if (this.hasScrollTarget) this.scrollTarget.scrollTop = savedScrollTop;
29
+ }
30
+ }
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: plutonium
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.49.0
4
+ version: 0.49.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-04 00:00:00.000000000 Z
10
+ date: 2026-05-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: zeitwerk
@@ -610,6 +610,8 @@ files:
610
610
  - docs/superpowers/plans/2026-04-08-plutonium-skills-overhaul.md
611
611
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md
612
612
  - docs/superpowers/plans/2026-04-14-plutonium-testing.md.tasks.json
613
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md
614
+ - docs/superpowers/plans/2026-05-06-multi-invite-model-support.md.tasks.json
613
615
  - docs/superpowers/specs/2026-04-08-plutonium-skills-overhaul-design.md
614
616
  - docs/superpowers/specs/2026-04-14-plutonium-testing-design.md
615
617
  - esbuild.config.js
@@ -1142,7 +1144,7 @@ metadata:
1142
1144
  homepage_uri: https://radioactive-labs.github.io/plutonium-core/
1143
1145
  source_code_uri: https://github.com/radioactive-labs/plutonium-core
1144
1146
  post_install_message: |
1145
- ⚠️ Plutonium 0.49.0 — breaking change
1147
+ ⚠️ Plutonium 0.49.1 — breaking change
1146
1148
 
1147
1149
  Entity-scoped URL helpers and path params have been renamed from
1148
1150
  `<entity>_scope_*` to `<entity>_scoped_*`.