plutonium 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -1,561 +1,242 @@
1
1
  # User Invites
2
2
 
3
- Plutonium provides a complete user invitation system for multi-tenant applications. This guide covers setting up invitations, customizing the flow, and integrating with your portals.
3
+ Set up token-based email invitations so admins can invite users into a tenant's membership.
4
4
 
5
- ## Overview
5
+ ## Goal
6
6
 
7
- The invitation system handles:
8
- - **Email Invitations**: Send secure invitation links to new or existing users
9
- - **Token Validation**: Time-limited tokens with automatic expiration
10
- - **Rodauth Integration**: Seamless signup and login flows
11
- - **Entity Memberships**: Automatic membership creation on acceptance
12
- - **Invitable Models**: Notify models when their invitations are accepted
7
+ An admin enters an email, the user gets an invite link, clicks it, signs up (or logs in if they already have an account) with that email, and is added to the org as a member.
13
8
 
14
9
  ## Prerequisites
15
10
 
16
- Before installing invites, ensure you have:
11
+ You need a user model, an entity model, and a membership model. The fastest path is `pu:saas:setup` — it creates all three and runs `pu:invites:install` automatically:
17
12
 
18
- 1. **User Authentication**: A Rodauth user account
19
- 2. **Entity Model**: An organization/company/team model
20
- 3. **Membership Model**: A join model linking users to entities
21
-
22
- The easiest way to set this up is with the SaaS generator:
23
13
  ```bash
24
- rails g pu:saas:setup --user User --entity Organization
14
+ rails g pu:saas:setup --user Customer --entity Organization
25
15
  ```
26
16
 
27
- ## Installation
17
+ For manual setup, ensure all three exist before running `pu:invites:install`.
18
+
19
+ ## Manual install
28
20
 
29
- ### Step 1: Install the Invites Package
21
+ ### 1. Run the generator
30
22
 
31
23
  ```bash
32
24
  rails generate pu:invites:install
33
25
  ```
34
26
 
35
- With custom models:
27
+ Or with custom models:
36
28
 
37
29
  ```bash
38
30
  rails g pu:invites:install \
39
31
  --entity-model=Organization \
40
- --user-model=User \
41
- --membership-model=OrganizationUser \
32
+ --user-model=Customer \
33
+ --membership-model=OrganizationCustomer \
42
34
  --roles=member,manager,admin
43
35
  ```
44
36
 
45
- ### Options
46
-
47
37
  | Option | Default | Description |
48
- |--------|---------|-------------|
49
- | `--entity-model` | Entity | Entity model for scoping invites |
50
- | `--user-model` | User | User account model |
51
- | `--invite-model` | `<EntityModel><UserModel>Invite` | Invite class name. Omit for single-flow apps; set per-invocation to run the generator more than once for distinct flows. |
52
- | `--membership-model` | EntityUser | Join model for memberships |
53
- | `--roles` | member,admin | Available invitation roles |
54
- | `--rodauth` | user | Rodauth configuration name |
55
- | `--enforce-domain` | false | Require email domain matching |
38
+ |---|---|---|
39
+ | `--entity-model=NAME` | `Entity` | Entity model |
40
+ | `--user-model=NAME` | `User` | User model |
41
+ | `--invite-model=NAME` | `<EntityModel><UserModel>Invite` | Invite class name |
42
+ | `--membership-model=NAME` | `EntityUser` | Membership join model |
43
+ | `--roles` | `member,admin` | Comma-separated roles |
44
+ | `--rodauth=NAME` | `user` | Rodauth configuration for signup |
45
+ | `--enforce-domain` | `false` | Require email domain to match entity |
56
46
 
57
- ### Step 2: Run Migrations
47
+ ### 2. Migrate
58
48
 
59
49
  ```bash
60
50
  rails db:migrate
61
51
  ```
62
52
 
63
- ### Step 3: Configure Your Portal
64
-
65
- Register the invites package in your portal:
53
+ ### 3. Connect to your portal
66
54
 
67
55
  ```ruby
68
56
  # packages/customer_portal/lib/engine.rb
69
57
  module CustomerPortal
70
58
  class Engine < Rails::Engine
71
59
  include Plutonium::Portal::Engine
72
-
73
60
  register_package Invites::Engine
74
61
  end
75
62
  end
76
63
  ```
77
64
 
78
- ## Generated Files
65
+ ### 4. Wire the post-login redirect
79
66
 
80
- The generator creates a complete `packages/invites/` package:
67
+ ```ruby
68
+ # app/rodauth/user_rodauth_plugin.rb
69
+ configure do
70
+ login_return_to_requested_location? true
71
+ login_redirect "/welcome"
81
72
 
82
- ```
83
- packages/invites/
84
- ├── app/
85
- │ ├── controllers/invites/
86
- │ │ ├── user_invitations_controller.rb # Invitation acceptance
87
- │ │ └── welcome_controller.rb # Post-login landing
88
- │ ├── definitions/invites/
89
- │ │ └── user_invite_definition.rb # UI configuration
90
- │ ├── interactions/invites/
91
- │ │ ├── cancel_invite_interaction.rb # Cancel action
92
- │ │ └── resend_invite_interaction.rb # Resend action
93
- │ ├── mailers/invites/
94
- │ │ └── user_invite_mailer.rb # Invitation emails
95
- │ ├── models/invites/
96
- │ │ └── user_invite.rb # Invite model
97
- │ ├── policies/invites/
98
- │ │ └── user_invite_policy.rb # Authorization
99
- │ └── views/invites/
100
- │ ├── user_invitations/ # Acceptance views
101
- │ ├── user_invite_mailer/ # Email templates
102
- │ └── welcome/ # Welcome page
103
- └── lib/
104
- └── engine.rb # Package engine
73
+ after_login do
74
+ session[:after_welcome_redirect] = session.delete(:login_redirect)
75
+ end
76
+ end
105
77
  ```
106
78
 
107
- ## Invitation Flow
79
+ Now users are redirected to `/welcome` after login, where pending invites are shown.
108
80
 
109
- ### Sending Invitations
81
+ ## The flow
110
82
 
111
- Admins can invite users from the entity detail page or user management:
83
+ ### 1. Admin sends the invite
112
84
 
113
85
  ```ruby
114
- # The generated action in your entity definition
115
- action :invite_user,
116
- interaction: Organization::InviteUserInteraction,
117
- category: :secondary
86
+ entity.invite_user(email: "user@example.com", role: :member)
118
87
  ```
119
88
 
120
- The interaction creates an `Invites::UserInvite` record and sends an email:
121
-
122
- ```ruby
123
- # Generated interaction
124
- class Organization::InviteUserInteraction < Plutonium::Interaction::Base
125
- attribute :email, :string
126
- attribute :role, :string, default: "member"
127
-
128
- validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
129
-
130
- def execute
131
- invite = Invites::UserInvite.create!(
132
- entity: resource,
133
- email: email,
134
- role: role,
135
- invited_by: current_user
136
- )
137
-
138
- succeed(invite)
139
- .with_message("Invitation sent to #{email}")
140
- end
141
- end
142
- ```
89
+ Or via the auto-generated "Invite User" action on the entity's show page.
143
90
 
144
- ### Accepting Invitations
91
+ ### 2. Email goes out
145
92
 
146
- #### Existing Users
93
+ Token-based URL: `https://app.example.com/invitations/abc123...`
147
94
 
148
- 1. User receives email with invitation link
149
- 2. Clicks link, sees invitation details
150
- 3. If logged in with matching email, accepts directly
151
- 4. If not logged in, redirected to login
152
- 5. After login, redirected back to accept
95
+ ### 3. User accepts
153
96
 
154
- #### New Users
97
+ **Existing user:** clicks link → logs in (or already logged in) → email validated → membership created.
155
98
 
156
- 1. User receives email with invitation link
157
- 2. Clicks link, sees invitation details
158
- 3. Clicks "Create Account"
159
- 4. Signs up with the invited email address
160
- 5. After signup, automatically accepts invitation
99
+ **New user:** clicks link → "Create Account" → signs up with the invited email → membership created.
161
100
 
162
- ### Post-Login Welcome
101
+ ### 4. After login
163
102
 
164
- After login, users land on `/welcome` where pending invitations are displayed:
103
+ Users land on `/welcome` where pending invites are shown. Including `Plutonium::Invites::PendingInviteCheck`:
165
104
 
166
105
  ```ruby
167
- # The WelcomeController checks for pending invites
168
- class Invites::WelcomeController < ApplicationController
169
- def index
170
- @pending_invites = Invites::UserInvite
171
- .pending
172
- .where(email: current_user.email)
173
-
174
- if @pending_invites.any?
175
- render :pending_invitation
176
- else
177
- redirect_to session.delete(:after_welcome_redirect) || root_path
178
- end
179
- end
180
- end
106
+ include Plutonium::Invites::PendingInviteCheck
181
107
  ```
182
108
 
183
- ## Invitables
184
-
185
- Invitables are models that trigger invitations and receive callbacks when accepted. Use this when you need to:
186
- - Create a record that requires a user to be assigned
187
- - Notify specific models when their invitation is accepted
188
- - Customize invitation behavior per model type
109
+ ## Invitables — app models notified on acceptance
189
110
 
190
- ### Creating an Invitable
111
+ An invitable is a model that gets notified when its invitation is accepted. Examples: `Tenant`, `TeamMember`, `ProjectCollaborator`.
191
112
 
192
113
  ```bash
193
114
  rails g pu:invites:invitable Tenant
194
115
  rails g pu:invites:invitable TeamMember --role=member
195
116
  ```
196
117
 
197
- ### Implementing the Callback
118
+ Then implement the callback:
198
119
 
199
120
  ```ruby
200
- # app/models/tenant.rb
201
121
  class Tenant < ApplicationRecord
202
122
  include Plutonium::Invites::Concerns::Invitable
203
123
 
204
- belongs_to :organization
124
+ belongs_to :entity
205
125
  belongs_to :user, optional: true
206
126
 
207
- # Called when the invitation is accepted
208
127
  def on_invite_accepted(user)
209
- update!(
210
- user: user,
211
- status: :active,
212
- activated_at: Time.current
213
- )
128
+ update!(user: user, status: :active)
214
129
  end
215
130
  end
216
131
  ```
217
132
 
218
- ### How Invitables Work
133
+ ::: warning Without `on_invite_accepted`
134
+ The invitable never learns about the new user — the invite is consumed but your app doesn't update its state.
135
+ :::
219
136
 
220
- When creating an invite from an invitable:
137
+ ## Multiple invite flows in one app
221
138
 
222
- ```ruby
223
- # The invitable triggers the invitation
224
- tenant.invite_user(email: "user@example.com")
139
+ Run `pu:invites:install` once per flow with different `--entity-model` / `--user-model` / `--invite-model`:
140
+
141
+ ```bash
142
+ rails g pu:invites:install \
143
+ --entity-model=FunderOrganization \
144
+ --user-model=SpenderAccount \
145
+ --invite-model=FunderInvite
225
146
 
226
- # Creates UserInvite with:
227
- # - invitable_type: "Tenant"
228
- # - invitable_id: tenant.id
147
+ rails g pu:invites:install \
148
+ --entity-model=Project \
149
+ --user-model=Member \
150
+ --invite-model=ProjectInvite
229
151
  ```
230
152
 
231
- When the invite is accepted:
153
+ Each invocation creates an independent flow: model, controller, route, helper all named for the invite-model.
232
154
 
233
- ```ruby
234
- # System calls:
235
- invite.accept_for_user!(user)
155
+ The shared `Invites::WelcomeController` accumulates each new class into its `invite_classes` array — `pending_invite` checks all flows in priority order (first-match wins).
236
156
 
237
- # Which internally:
238
- # 1. Creates entity membership
239
- # 2. Calls tenant.on_invite_accepted(user)
240
- ```
157
+ See [Reference › Tenancy › Invites › Multiple invite flows](/reference/tenancy/invites#multiple-invite-flows).
241
158
 
242
159
  ## Customization
243
160
 
244
- ### Custom Email Templates
161
+ ### Email templates
245
162
 
246
- Override the default templates:
163
+ Override views in your package:
247
164
 
248
165
  ```erb
249
166
  <%# packages/invites/app/views/invites/user_invite_mailer/invitation.html.erb %>
250
- <!DOCTYPE html>
251
- <html>
252
- <body>
253
- <h1>Welcome to <%= @invite.entity.name %>!</h1>
254
-
255
- <p>
256
- <%= @invite.invited_by.email %> has invited you to join
257
- as a <%= @invite.role %>.
258
- </p>
259
-
260
- <p>
261
- <%= link_to "Accept Invitation", @invitation_url,
262
- style: "background: #4F46E5; color: white; padding: 12px 24px;" %>
263
- </p>
264
-
265
- <p>This invitation expires in 7 days.</p>
266
- </body>
267
- </html>
167
+ <h1>Welcome to <%= @invite.entity.name %>!</h1>
168
+ <p><%= @invite.invited_by.email %> has invited you.</p>
169
+ <p><%= link_to "Accept", @invitation_url %></p>
268
170
  ```
269
171
 
270
- ### Per-Invitable Templates
271
-
272
- Create model-specific email templates:
273
-
274
- ```erb
275
- <%# packages/invites/app/views/invites/user_invite_mailer/invitation_tenant.html.erb %>
276
- <h1>You've been assigned as a tenant!</h1>
277
- <p>Accept to access your tenant dashboard.</p>
278
- ```
279
-
280
- ### Custom Validation
281
-
282
- Add validation to the invite model:
172
+ ### Custom validation
283
173
 
284
174
  ```ruby
285
- # packages/invites/app/models/invites/user_invite.rb
286
175
  class Invites::UserInvite < Invites::ResourceRecord
287
176
  validate :email_not_already_member
288
- validate :within_invite_limit
289
177
 
290
178
  private
291
179
 
292
180
  def email_not_already_member
293
- if entity.users.exists?(email: email)
294
- errors.add(:email, "is already a member of this organization")
295
- end
296
- end
297
-
298
- def within_invite_limit
299
- pending_count = entity.user_invites.pending.count
300
- if pending_count >= 100
301
- errors.add(:base, "Maximum pending invitations reached")
302
- end
181
+ existing = membership_model.joins(:user)
182
+ .where(entity: entity, users: {email: email}).exists?
183
+ errors.add(:email, "is already a member") if existing
303
184
  end
304
185
  end
305
186
  ```
306
187
 
307
- ### Domain Enforcement
308
-
309
- Require invited emails to match the entity's domain:
188
+ ### Domain enforcement
310
189
 
311
190
  ```bash
312
191
  rails g pu:invites:install --enforce-domain
313
192
  ```
314
193
 
315
- Or implement custom domain logic:
316
-
317
- ```ruby
318
- # packages/invites/app/models/invites/user_invite.rb
319
- def enforce_domain
320
- entity.domain # e.g., "acme.com"
321
- end
322
- ```
323
-
324
- ### Custom Expiration
325
-
326
- Change the default expiration time:
327
-
328
- ```ruby
329
- # packages/invites/app/models/invites/user_invite.rb
330
- private
331
-
332
- def set_token_defaults
333
- self.token ||= SecureRandom.urlsafe_base64(32)
334
- self.expires_at ||= 3.days.from_now # Override default 1 week
335
- end
336
- ```
337
-
338
- ## Managing Invitations
339
-
340
- ### Resend Invitation
341
-
342
- The generated `ResendInviteInteraction` allows resending:
343
-
344
- ```ruby
345
- # Resets expiration and sends new email
346
- invite.resend!
347
- ```
348
-
349
- ### Cancel Invitation
350
-
351
- ```ruby
352
- invite.cancel!
353
- # Sets state to :cancelled
354
- ```
355
-
356
- ### View Pending Invitations
357
-
358
- In your admin portal:
359
-
360
- ```ruby
361
- # Invites are scoped to the current entity
362
- # Admins see all pending invites for their organization
363
- Invites::UserInvite.pending.where(entity: current_scoped_entity)
364
- ```
365
-
366
- ## Security Considerations
367
-
368
- ### Token Security
369
-
370
- - Tokens are 32-byte URL-safe base64 strings
371
- - Tokens expire after 1 week by default
372
- - Each invite has a unique token
373
-
374
- ### Email Validation
194
+ Requires the invited email domain to match the entity's domain.
375
195
 
376
- By default, the accepting user's email must match the invited email:
196
+ ### Custom expiration
377
197
 
378
198
  ```ruby
379
- def enforce_email?
380
- true # Default: require exact match
381
- end
382
- ```
383
-
384
- ### Rate Limiting
385
-
386
- Consider adding rate limiting to prevent abuse:
387
-
388
- ```ruby
389
- # In your interaction
390
- validate :rate_limit_invites
391
-
392
- def rate_limit_invites
393
- recent = Invites::UserInvite
394
- .where(invited_by: current_user)
395
- .where("created_at > ?", 1.hour.ago)
396
- .count
397
-
398
- if recent >= 50
399
- errors.add(:base, "Too many invitations sent. Please wait.")
400
- end
401
- end
402
- ```
403
-
404
- ## Troubleshooting
405
-
406
- ### "Invitation not found or expired"
407
-
408
- - Check that the token hasn't expired (default: 1 week)
409
- - Verify the invite is still `pending` (not cancelled or accepted)
410
- - Ensure the URL is complete and not truncated
411
-
412
- ### "Email mismatch" Error
413
-
414
- The system requires the accepting user's email to match:
415
-
416
- ```
417
- This invitation is for user@example.com.
418
- You must use an account with that email address.
419
- ```
420
-
421
- If you need to allow any email:
422
-
423
- ```ruby
424
- def enforce_email?
425
- false # Not recommended for security
426
- end
427
- ```
428
-
429
- ### Rodauth Not Redirecting Properly
430
-
431
- Ensure your Rodauth plugin is configured:
432
-
433
- ```ruby
434
- # app/rodauth/user_rodauth_plugin.rb
435
- configure do
436
- login_return_to_requested_location? true
437
- login_redirect "/welcome"
438
-
439
- after_login do
440
- session[:after_welcome_redirect] = session.delete(:login_redirect)
441
- end
442
- end
443
- ```
444
-
445
- ### Invitable Callback Not Called
446
-
447
- Ensure your model includes the concern and implements the callback:
448
-
449
- ```ruby
450
- class Tenant < ApplicationRecord
451
- include Plutonium::Invites::Concerns::Invitable
199
+ class Invites::UserInvite < Invites::ResourceRecord
200
+ TOKEN_EXPIRATION = 30.days # default: 1 week
452
201
 
453
- def on_invite_accepted(user)
454
- # This MUST be implemented
455
- update!(user: user)
202
+ def expired?
203
+ created_at < TOKEN_EXPIRATION.ago
456
204
  end
457
205
  end
458
206
  ```
459
207
 
460
- ## API Reference
461
-
462
- ### UserInvite States
463
-
464
- | State | Description |
465
- |-------|-------------|
466
- | `pending` | Awaiting acceptance |
467
- | `accepted` | Successfully accepted |
468
- | `expired` | Past expiration date |
469
- | `cancelled` | Manually cancelled |
470
-
471
- ### Key Methods
208
+ ## Managing invitations
472
209
 
473
210
  ```ruby
474
- # Find valid invite
475
- invite = Invites::UserInvite.find_for_acceptance(token)
476
-
477
- # Accept invitation
478
- invite.accept_for_user!(user)
479
-
480
- # Resend email
481
- invite.resend!
482
-
483
- # Cancel
484
- invite.cancel!
485
-
486
- # Check state
487
- invite.pending?
488
- invite.accepted?
489
- invite.expired?
490
- invite.cancelled?
491
- ```
492
-
493
- ## Multiple invite flows in one app
494
-
495
- Some apps invite users to several distinct kinds of entity — for example, customers joining organizations and funders joining projects. Run `pu:invites:install` once per flow; each invocation produces independent migrations, models, policies, definitions, mailers, controllers, view templates, and route helpers.
496
-
497
- ### Default-derivation rule
498
-
499
- When you omit `--invite-model`, the generator derives the class name as `<EntityModel><UserModel>Invite`. With the defaults (`--entity-model=Organization --user-model=User`) the generated class is `Invites::OrganizationUserInvite` — there is no literal `UserInvite` default. Single-flow apps never need to pass `--invite-model`.
500
-
501
- Multi-flow apps either:
502
-
503
- - Run the generator more than once with the **same** entity/user but different `--invite-model` values (rare), or
504
- - Run with **different** `--entity-model` / `--user-model` pairs (common). Different derivations make `--invite-model` unnecessary; pass it only when you want a custom class name.
505
-
506
- ```bash
507
- rails g pu:invites:install \
508
- --entity-model=FunderOrganization \
509
- --user-model=SpenderAccount \
510
- --invite-model=FunderInvite
211
+ invite.resend! # generates new token + sends email
212
+ invite.cancel! # transitions to :cancelled state
511
213
 
512
- rails g pu:invites:install \
513
- --entity-model=Project \
514
- --user-model=Member \
515
- --invite-model=ProjectInvite
214
+ entity.user_invites.pending # list pending
516
215
  ```
517
216
 
518
- After the second invocation you'll have, for example, `Invites::FunderInvite` on table `funder_invites` with controller `Invites::FunderInvitationsController` mounted at `/funder_invitations/:token`, alongside `Invites::ProjectInvite` on `project_invites` mounted at `/project_invitations/:token`. Route helpers are prefixed (`funder_invitation_path`, `project_invitation_path`).
519
-
520
- ### How the welcome flow finds pending invites
521
-
522
- The shared `Invites::WelcomeController` keeps a running list of invite classes. Each install run injects its class into the array, preserving order:
523
-
524
- ```ruby
525
- # packages/invites/app/controllers/invites/welcome_controller.rb
526
- def invite_classes
527
- [::Invites::FunderInvite, ::Invites::ProjectInvite]
528
- end
529
- ```
217
+ ## Security
530
218
 
531
- After login, `Plutonium::Invites::PendingInviteCheck#pending_invite` iterates `invite_classes` and returns the first valid pending invite for the cookie-stored token (first-match wins). Plug a third-party invite class in by overriding `invite_classes` directly:
219
+ - **Token security** `SecureRandom.urlsafe_base64(32)` 256 bits, URL-safe. Stored hashed, raw token shown only at creation.
220
+ - **Email validation** — `enforce_email?` is `true` by default. The accepting user's email must match the invited email — prevents account hijacking via invite forwarding.
221
+ - **Rate limiting** — use Rack::Attack or similar to throttle invite creation per admin and acceptance attempts per IP.
532
222
 
223
+ ::: danger Don't disable enforce_email?
533
224
  ```ruby
534
- class WelcomeController < ApplicationController
535
- include Plutonium::Invites::PendingInviteCheck
536
-
537
- def invite_classes
538
- [::Invites::FunderInvite, ::Invites::ProjectInvite, ::Foreign::ApiInvite]
539
- end
540
- end
225
+ def enforce_email? = false # ← only if you fully understand the trade-off
541
226
  ```
227
+ Without this, anyone with the token can sign up — defeats the purpose of an invitation system.
228
+ :::
542
229
 
543
- ### Per-flow controller overrides
544
-
545
- The install generator emits an `invitation_path_for` override on each invitations controller so post-login redirects target the correct prefixed route:
546
-
547
- ```ruby
548
- # packages/invites/app/controllers/invites/funder_invitations_controller.rb
549
- def invitation_path_for(token)
550
- funder_invitation_path(token: token)
551
- end
552
- ```
230
+ ## Common issues
553
231
 
554
- You won't normally edit thisbut it's worth knowing the hook exists, because that's how multi-flow controllers stay independent from the shared `Plutonium::Invites::Controller` concern.
232
+ - **"Invitation not found or expired"** token expired (default 1 week), invite cancelled, or no longer `pending`.
233
+ - **Email mismatch error** — the accepting user's email doesn't match the invited email. This is by design (security).
234
+ - **Rodauth redirect after login doesn't go to `/welcome`** — check `login_redirect "/welcome"` in the rodauth plugin's `configure` block.
235
+ - **`on_invite_accepted` not called** — ensure the invitable model `include Plutonium::Invites::Concerns::Invitable` and defines `on_invite_accepted`.
555
236
 
556
- ## Next Steps
237
+ ## Related
557
238
 
558
- - [Authentication](/guides/authentication) - Set up Rodauth
559
- - [Authorization](/guides/authorization) - Configure policies
560
- - [Custom Actions](/guides/custom-actions) - Add more invite actions
561
- - [Multi-tenancy](/guides/multi-tenancy) - Entity scoping
239
+ - [Reference › Tenancy › Invites](/reference/tenancy/invites) — full surface, multi-flow apps, customization
240
+ - [Multi-tenancy](./multi-tenancy) — entity scoping (invites are entity-scoped automatically)
241
+ - [Authentication](./authentication) Rodauth setup
242
+ - [User profile](./user-profile) — account-settings page